capt-hook 2.0.0__tar.gz → 2.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {capt_hook-2.0.0 → capt_hook-2.1.0}/PKG-INFO +32 -41
- capt_hook-2.1.0/README.md +89 -0
- capt_hook-2.1.0/captain_hook/__init__.py +95 -0
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/app.py +3 -4
- capt_hook-2.1.0/captain_hook/ast_grep.py +136 -0
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/cli.py +217 -85
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/command.py +31 -0
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/conditions.py +10 -2
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/context.py +33 -21
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/decisions.py +7 -2
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/dispatch.py +10 -1
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/events.py +47 -1
- capt_hook-2.1.0/captain_hook/loader.py +122 -0
- capt_hook-2.1.0/captain_hook/packs/__init__.py +1 -0
- capt_hook-2.1.0/captain_hook/packs/general/__init__.py +1 -0
- capt_hook-2.1.0/captain_hook/packs/general/capt-hook.toml +4 -0
- capt_hook-2.1.0/captain_hook/packs/general/commands.py +40 -0
- capt_hook-2.1.0/captain_hook/packs/general/docs.py +25 -0
- capt_hook-2.1.0/captain_hook/packs/general/plans.py +111 -0
- capt_hook-2.1.0/captain_hook/packs/general/prompts.py +33 -0
- capt_hook-2.1.0/captain_hook/packs/general/review.py +75 -0
- capt_hook-2.1.0/captain_hook/packs/general/tasks.py +147 -0
- capt_hook-2.1.0/captain_hook/packs/go/__init__.py +1 -0
- capt_hook-2.1.0/captain_hook/packs/go/capt-hook.toml +4 -0
- capt_hook-2.1.0/captain_hook/packs/go/testing.py +87 -0
- capt_hook-2.1.0/captain_hook/packs/go/toolchain.py +24 -0
- capt_hook-2.1.0/captain_hook/packs/manager.py +451 -0
- capt_hook-2.1.0/captain_hook/packs/python/__init__.py +1 -0
- capt_hook-2.1.0/captain_hook/packs/python/capt-hook.toml +4 -0
- capt_hook-2.1.0/captain_hook/packs/python/style.py +157 -0
- capt_hook-2.1.0/captain_hook/packs/python/testing.py +87 -0
- capt_hook-2.1.0/captain_hook/packs/python/toolchain.py +19 -0
- capt_hook-2.1.0/captain_hook/packs/steering/__init__.py +25 -0
- capt_hook-2.1.0/captain_hook/packs/steering/capt-hook.toml +4 -0
- capt_hook-2.1.0/captain_hook/packs/steering/lib.py +104 -0
- capt_hook-2.1.0/captain_hook/packs/steering/steering.py +191 -0
- capt_hook-2.1.0/captain_hook/primitives/__init__.py +15 -0
- capt_hook-2.1.0/captain_hook/primitives/commands.py +139 -0
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/primitives/lint.py +54 -5
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/primitives/nudge.py +3 -1
- capt_hook-2.1.0/captain_hook/primitives/rewrite.py +64 -0
- capt_hook-2.1.0/captain_hook/review/__init__.py +3 -0
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/review/cli.py +72 -17
- capt_hook-2.1.0/captain_hook/review/dashboard.py +216 -0
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/review/formats.py +1 -3
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/review/judge.py +2 -2
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/review/pipeline.py +26 -8
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/review/scan.py +36 -2
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/review/settings.py +1 -1
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/review/store.py +147 -21
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/review/sync.py +5 -2
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/settings.py +2 -2
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/signals/nlp.py +7 -3
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/skills/authoring-hooks/SKILL.md +3 -3
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +28 -3
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +1 -1
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +1 -1
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +29 -23
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/skills/scanning-sessions/SKILL.md +3 -3
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +5 -4
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/skills/translating-styleguides/SKILL.md +1 -1
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/state.py +30 -18
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/style/__init__.py +16 -38
- capt_hook-2.1.0/captain_hook/style/ast_grep.py +116 -0
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/style/types.py +37 -4
- capt_hook-2.1.0/captain_hook/testing/__init__.py +1 -0
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/testing/helpers.py +43 -8
- capt_hook-2.1.0/captain_hook/testing/types.py +147 -0
- capt_hook-2.1.0/captain_hook/tests/__init__.py +13 -0
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/tests/helpers.py +4 -4
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/types.py +106 -2
- capt_hook-2.1.0/captain_hook/util/http.py +133 -0
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/util/model_cache.py +7 -6
- capt_hook-2.1.0/captain_hook/utils.py +47 -0
- {capt_hook-2.0.0 → capt_hook-2.1.0}/pyproject.toml +12 -3
- capt_hook-2.0.0/README.md +0 -100
- capt_hook-2.0.0/captain_hook/__init__.py +0 -89
- capt_hook-2.0.0/captain_hook/loader.py +0 -63
- capt_hook-2.0.0/captain_hook/primitives/__init__.py +0 -15
- capt_hook-2.0.0/captain_hook/primitives/commands.py +0 -62
- capt_hook-2.0.0/captain_hook/review/__init__.py +0 -6
- capt_hook-2.0.0/captain_hook/testing/__init__.py +0 -6
- capt_hook-2.0.0/captain_hook/testing/types.py +0 -90
- capt_hook-2.0.0/captain_hook/tests/__init__.py +0 -11
- capt_hook-2.0.0/captain_hook/utils.py +0 -27
- {capt_hook-2.0.0 → capt_hook-2.1.0}/LICENSE +0 -0
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/.claude-plugin/plugin.json +0 -0
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/__main__.py +0 -0
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/classifiers/__init__.py +0 -0
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/classifiers/conductor.py +0 -0
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/classifiers/droid.py +0 -0
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/classifiers/native.py +0 -0
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/file.py +0 -0
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/llm/__init__.py +0 -0
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/log.py +0 -0
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/primitives/llm.py +0 -0
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/primitives/workflow.py +0 -0
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/prompt.py +0 -0
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/py.typed +0 -0
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/review/fix.py +0 -0
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/review/repo.py +0 -0
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/session.py +0 -0
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/signals/__init__.py +0 -0
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +0 -0
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/style/matchers.py +0 -0
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/style/scope.py +0 -0
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/tasks.py +0 -0
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/testing/session_cache.py +0 -0
- {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/util/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: capt-hook
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: Declarative hook framework for Claude Code
|
|
5
5
|
Keywords: claude,claude-code,hooks,llm,agents,guardrails,cli
|
|
6
6
|
Author: Yasyf Mohamedali
|
|
@@ -16,20 +16,22 @@ Classifier: Programming Language :: Python :: 3 :: Only
|
|
|
16
16
|
Classifier: Topic :: Software Development :: Quality Assurance
|
|
17
17
|
Classifier: Topic :: Software Development :: Testing
|
|
18
18
|
Classifier: Typing :: Typed
|
|
19
|
-
Requires-Dist: cc-transcript>=
|
|
19
|
+
Requires-Dist: cc-transcript>=5,<6
|
|
20
20
|
Requires-Dist: pydantic>=2.0
|
|
21
21
|
Requires-Dist: pydantic-settings>=2.0
|
|
22
22
|
Requires-Dist: tree-sitter>=0.24
|
|
23
23
|
Requires-Dist: tree-sitter-bash>=0.23
|
|
24
|
+
Requires-Dist: ast-grep-py>=0.39,<1
|
|
24
25
|
Requires-Dist: funcy>=2.0
|
|
25
26
|
Requires-Dist: spacy>=3.7
|
|
26
27
|
Requires-Dist: click>=8
|
|
28
|
+
Requires-Dist: rich>=13
|
|
27
29
|
Requires-Dist: orjsonl>=1.0
|
|
28
30
|
Requires-Dist: wn>=1.1.0
|
|
29
31
|
Requires-Dist: lazy-object-proxy>=1.12.0
|
|
30
32
|
Requires-Dist: filelock>=3
|
|
31
33
|
Requires-Dist: loguru>=0.7.3
|
|
32
|
-
Requires-Dist: spawnllm>=0.
|
|
34
|
+
Requires-Dist: spawnllm>=0.4.0
|
|
33
35
|
Requires-Dist: pytest>=8.0 ; extra == 'dev'
|
|
34
36
|
Requires-Dist: pytest-asyncio>=0.24 ; extra == 'dev'
|
|
35
37
|
Requires-Dist: pyright>=1.1 ; extra == 'dev'
|
|
@@ -53,48 +55,48 @@ Description-Content-Type: text/markdown
|
|
|
53
55
|
[](https://yasyf.github.io/captain-hook/)
|
|
54
56
|
[](https://github.com/yasyf/captain-hook/blob/main/LICENSE)
|
|
55
57
|
|
|
56
|
-
|
|
58
|
+
Guardrails for Claude Code, written as typed, testable data — and learned from the corrections you give Claude.
|
|
59
|
+
|
|
60
|
+
A captain-hook hook is declarative Python: an event, some conditions, an action. Block a footgun before it runs, nudge the agent off a bad pattern, gate "done" until the tests pass. Then captain-hook closes the loop: it reads the corrections you give Claude as you work and opens pull requests that codify the durable ones as new hooks. You write the first few; it writes the rest.
|
|
57
61
|
|
|
58
62
|
## Install
|
|
59
63
|
|
|
60
|
-
|
|
64
|
+
captain-hook needs no install — it runs through [uvx](https://docs.astral.sh/uv/). From your project root:
|
|
61
65
|
|
|
62
66
|
```bash
|
|
63
67
|
uvx capt-hook init
|
|
64
68
|
```
|
|
65
69
|
|
|
66
|
-
`
|
|
67
|
-
|
|
68
|
-
## First hook
|
|
70
|
+
`init` scaffolds `.claude/hooks/`, wires Claude Code's settings, registers the captain-hook plugin so its skills install on workspace-trust, and arms the [session reviewer](#it-learns-from-your-corrections). Or do it all from a session. Run `/plugin marketplace add yasyf/captain-hook`, then ask Claude to "set up captain hook".
|
|
69
71
|
|
|
70
|
-
|
|
72
|
+
## Your first hook
|
|
71
73
|
|
|
72
|
-
|
|
73
|
-
uvx capt-hook init
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
A hook is declarative Python with an event, some conditions, and an action. This one stops the agent from finishing a UI change it never looked at.
|
|
74
|
+
A hook is an event, some conditions, and an action. This one stops the agent from finishing a UI change it never looked at:
|
|
77
75
|
|
|
78
76
|
```python
|
|
79
77
|
# .claude/hooks/visual_review.py
|
|
80
78
|
from captain_hook import gate, TouchedFile, UsedSkill
|
|
81
79
|
|
|
82
|
-
# A Stop gate: before the agent finishes, block if it edited UI files without doing a visual review.
|
|
83
80
|
gate(
|
|
84
|
-
# the one-line reason shown to the agent when the gate fires
|
|
85
81
|
"You edited UI files. Open them with agent-browser and verify they render before finishing.",
|
|
86
|
-
# fires only if UI files changed
|
|
87
82
|
only_if=[TouchedFile("**/src/routes/**", "**/src/components/**")],
|
|
88
|
-
# already reviewed -> don't block
|
|
89
83
|
skip_if=[UsedSkill("agent-browser")],
|
|
90
84
|
)
|
|
91
85
|
```
|
|
92
86
|
|
|
93
|
-
Conditions match tools, files, commands, and even which skills the agent used.
|
|
87
|
+
`only_if` arms the gate only when UI files changed; `skip_if` stands it down once the agent has done the review. Conditions match tools, files, commands, and even which skills the agent used.
|
|
88
|
+
|
|
89
|
+
## It learns from your corrections
|
|
90
|
+
|
|
91
|
+
Most hooks you'll never write by hand.
|
|
92
|
+
|
|
93
|
+
The corrections you give Claude as you work are exactly the rules a hook should enforce: "never force-push", "use `uv`, not `pip`", "you weakened that test". Writing the hook by hand is friction you skip in the moment, so the **session reviewer** notices for you. When a session ends, it reads the transcript, finds the durable corrections and the hooks that misfired, judges which ones are standing rules and which are one-offs, and once a pattern proves itself across sessions, opens a pull request that adds the hook — or fixes the one that misfired. You review the PR like any other.
|
|
94
94
|
|
|
95
|
-
|
|
95
|
+
It's on by default after `init`. Turn it off for a repo with `uvx capt-hook review disable`. The [session reviewer guide](https://yasyf.github.io/captain-hook/docs/guide/session-reviewer.html) covers the prerequisites (an authenticated `claude` and `gh`) and the `HOOKS_REVIEW_*` thresholds.
|
|
96
96
|
|
|
97
|
-
|
|
97
|
+
## Tested like code
|
|
98
|
+
|
|
99
|
+
Every deterministic hook carries inline tests, so a broken hook fails like broken code:
|
|
98
100
|
|
|
99
101
|
```python
|
|
100
102
|
# .claude/hooks/safety.py
|
|
@@ -111,35 +113,24 @@ block_command(
|
|
|
111
113
|
)
|
|
112
114
|
```
|
|
113
115
|
|
|
116
|
+
Run them from your project root, where `--hooks` defaults to `.claude/hooks`:
|
|
117
|
+
|
|
114
118
|
```bash
|
|
115
119
|
uvx capt-hook test
|
|
116
120
|
```
|
|
117
121
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
## Agent Skills & plugin
|
|
122
|
+
Wire that into CI and you catch a broken hook the way you catch broken code.
|
|
121
123
|
|
|
122
|
-
|
|
124
|
+
## What it's for
|
|
123
125
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
## What this solves
|
|
130
|
-
|
|
131
|
-
captain-hook covers these jobs:
|
|
132
|
-
|
|
133
|
-
- Block dangerous tool calls before they execute on `PreToolUse`, like force-push, package-manager footguns, and raw `rm -rf`.
|
|
134
|
-
- Drive the agent with feedback that fires on the patterns it actually emits, such as repeated failures, weakened tests, and missed conventions.
|
|
135
|
-
- Enforce multi-step workflows with Stop gates and artifact validation, so the agent can't declare "done" without running tests, writing a report, or completing a checklist.
|
|
136
|
-
- Keep all of the above testable. Every hook ships with inline `tests = {...}` that `uvx capt-hook test` runs in CI, so you catch broken hooks the way you catch broken code.
|
|
126
|
+
- Block footguns before they run on `PreToolUse`: force-push, `rm -rf`, package-manager traps.
|
|
127
|
+
- Steer the agent with feedback that fires on the patterns it actually emits: repeated failures, weakened tests, missed conventions.
|
|
128
|
+
- Hold the line on multi-step work with Stop gates and artifact checks, so the agent can't call it "done" before the tests run or the report's written.
|
|
129
|
+
- Keep all of it testable; every hook ships with inline tests that run in CI.
|
|
137
130
|
|
|
138
131
|
## Docs
|
|
139
132
|
|
|
140
|
-
[Read the docs](https://yasyf.github.io/captain-hook/) for the full guide to conditions, primitives, LLM hooks, workflows, state, and real-world patterns.
|
|
141
|
-
|
|
142
|
-
For working on captain-hook itself, see the [development guide](https://yasyf.github.io/captain-hook/docs/development/).
|
|
133
|
+
[Read the docs](https://yasyf.github.io/captain-hook/) for the full guide to conditions, primitives, LLM hooks, workflows, state, and real-world patterns. To work on captain-hook itself, see the [development guide](https://yasyf.github.io/captain-hook/docs/development/).
|
|
143
134
|
|
|
144
135
|
## License
|
|
145
136
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# captain-hook
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
[](https://pypi.org/project/capt-hook/)
|
|
6
|
+
[](https://pypi.org/project/capt-hook/)
|
|
7
|
+
[](https://yasyf.github.io/captain-hook/)
|
|
8
|
+
[](https://github.com/yasyf/captain-hook/blob/main/LICENSE)
|
|
9
|
+
|
|
10
|
+
Guardrails for Claude Code, written as typed, testable data — and learned from the corrections you give Claude.
|
|
11
|
+
|
|
12
|
+
A captain-hook hook is declarative Python: an event, some conditions, an action. Block a footgun before it runs, nudge the agent off a bad pattern, gate "done" until the tests pass. Then captain-hook closes the loop: it reads the corrections you give Claude as you work and opens pull requests that codify the durable ones as new hooks. You write the first few; it writes the rest.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
captain-hook needs no install — it runs through [uvx](https://docs.astral.sh/uv/). From your project root:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
uvx capt-hook init
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`init` scaffolds `.claude/hooks/`, wires Claude Code's settings, registers the captain-hook plugin so its skills install on workspace-trust, and arms the [session reviewer](#it-learns-from-your-corrections). Or do it all from a session. Run `/plugin marketplace add yasyf/captain-hook`, then ask Claude to "set up captain hook".
|
|
23
|
+
|
|
24
|
+
## Your first hook
|
|
25
|
+
|
|
26
|
+
A hook is an event, some conditions, and an action. This one stops the agent from finishing a UI change it never looked at:
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
# .claude/hooks/visual_review.py
|
|
30
|
+
from captain_hook import gate, TouchedFile, UsedSkill
|
|
31
|
+
|
|
32
|
+
gate(
|
|
33
|
+
"You edited UI files. Open them with agent-browser and verify they render before finishing.",
|
|
34
|
+
only_if=[TouchedFile("**/src/routes/**", "**/src/components/**")],
|
|
35
|
+
skip_if=[UsedSkill("agent-browser")],
|
|
36
|
+
)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`only_if` arms the gate only when UI files changed; `skip_if` stands it down once the agent has done the review. Conditions match tools, files, commands, and even which skills the agent used.
|
|
40
|
+
|
|
41
|
+
## It learns from your corrections
|
|
42
|
+
|
|
43
|
+
Most hooks you'll never write by hand.
|
|
44
|
+
|
|
45
|
+
The corrections you give Claude as you work are exactly the rules a hook should enforce: "never force-push", "use `uv`, not `pip`", "you weakened that test". Writing the hook by hand is friction you skip in the moment, so the **session reviewer** notices for you. When a session ends, it reads the transcript, finds the durable corrections and the hooks that misfired, judges which ones are standing rules and which are one-offs, and once a pattern proves itself across sessions, opens a pull request that adds the hook — or fixes the one that misfired. You review the PR like any other.
|
|
46
|
+
|
|
47
|
+
It's on by default after `init`. Turn it off for a repo with `uvx capt-hook review disable`. The [session reviewer guide](https://yasyf.github.io/captain-hook/docs/guide/session-reviewer.html) covers the prerequisites (an authenticated `claude` and `gh`) and the `HOOKS_REVIEW_*` thresholds.
|
|
48
|
+
|
|
49
|
+
## Tested like code
|
|
50
|
+
|
|
51
|
+
Every deterministic hook carries inline tests, so a broken hook fails like broken code:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
# .claude/hooks/safety.py
|
|
55
|
+
from captain_hook import Allow, Block, Input, block_command
|
|
56
|
+
|
|
57
|
+
block_command(
|
|
58
|
+
["git", "stash"],
|
|
59
|
+
reason="Use the team's VCS workflow for shelving changes",
|
|
60
|
+
hint="Commit a WIP change instead of stashing",
|
|
61
|
+
tests={
|
|
62
|
+
Input(command="git stash"): Block(),
|
|
63
|
+
Input(command="git status"): Allow(),
|
|
64
|
+
},
|
|
65
|
+
)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Run them from your project root, where `--hooks` defaults to `.claude/hooks`:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
uvx capt-hook test
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Wire that into CI and you catch a broken hook the way you catch broken code.
|
|
75
|
+
|
|
76
|
+
## What it's for
|
|
77
|
+
|
|
78
|
+
- Block footguns before they run on `PreToolUse`: force-push, `rm -rf`, package-manager traps.
|
|
79
|
+
- Steer the agent with feedback that fires on the patterns it actually emits: repeated failures, weakened tests, missed conventions.
|
|
80
|
+
- Hold the line on multi-step work with Stop gates and artifact checks, so the agent can't call it "done" before the tests run or the report's written.
|
|
81
|
+
- Keep all of it testable; every hook ships with inline tests that run in CI.
|
|
82
|
+
|
|
83
|
+
## Docs
|
|
84
|
+
|
|
85
|
+
[Read the docs](https://yasyf.github.io/captain-hook/) for the full guide to conditions, primitives, LLM hooks, workflows, state, and real-world patterns. To work on captain-hook itself, see the [development guide](https://yasyf.github.io/captain-hook/docs/development/).
|
|
86
|
+
|
|
87
|
+
## License
|
|
88
|
+
|
|
89
|
+
Licensed under [PolyForm Noncommercial 1.0.0](LICENSE), free for noncommercial use.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from cc_transcript.tools import (
|
|
4
|
+
BashCall,
|
|
5
|
+
EditCall,
|
|
6
|
+
ExitPlanModeCall,
|
|
7
|
+
GlobCall,
|
|
8
|
+
GrepCall,
|
|
9
|
+
MultiEditCall,
|
|
10
|
+
NotebookEditCall,
|
|
11
|
+
OtherCall,
|
|
12
|
+
ReadCall,
|
|
13
|
+
SkillCall,
|
|
14
|
+
TaskCall,
|
|
15
|
+
TaskCreateCall,
|
|
16
|
+
TaskUpdateCall,
|
|
17
|
+
ToolCall,
|
|
18
|
+
ToolCallBase,
|
|
19
|
+
WriteCall,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from captain_hook import file, style, utils
|
|
23
|
+
from captain_hook.app import hook, on, register
|
|
24
|
+
from captain_hook.command import Command, CommandLine, Redirect
|
|
25
|
+
from captain_hook.context import HookContext
|
|
26
|
+
from captain_hook.events import (
|
|
27
|
+
BaseHookEvent,
|
|
28
|
+
NotificationEvent,
|
|
29
|
+
PostToolUseEvent,
|
|
30
|
+
PostToolUseFailureEvent,
|
|
31
|
+
PreCompactEvent,
|
|
32
|
+
PreToolUseEvent,
|
|
33
|
+
SessionEndEvent,
|
|
34
|
+
StopEvent,
|
|
35
|
+
SubagentStartEvent,
|
|
36
|
+
SubagentStopEvent,
|
|
37
|
+
ToolHookEvent,
|
|
38
|
+
UserPromptSubmitEvent,
|
|
39
|
+
)
|
|
40
|
+
from captain_hook.file import File, categorize_files
|
|
41
|
+
from captain_hook.primitives import (
|
|
42
|
+
GateVerdict,
|
|
43
|
+
NudgeVerdict,
|
|
44
|
+
PromptCheckVerdict,
|
|
45
|
+
block_command,
|
|
46
|
+
gate,
|
|
47
|
+
llm_evaluate,
|
|
48
|
+
llm_gate,
|
|
49
|
+
llm_nudge,
|
|
50
|
+
prompt_check,
|
|
51
|
+
rewrite_code,
|
|
52
|
+
rewrite_command,
|
|
53
|
+
warn_command,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# lint/nudge are imported from their defining modules, not the primitives
|
|
57
|
+
# package: the package attribute and the submodule share a name, and an alias
|
|
58
|
+
# targeting captain_hook.primitives.<name> resolves to the module under static
|
|
59
|
+
# analysis (griffe), shadowing the function.
|
|
60
|
+
from captain_hook.primitives.lint import diff_lint, lint
|
|
61
|
+
from captain_hook.primitives.nudge import nudge
|
|
62
|
+
from captain_hook.primitives.workflow import Artifact, Step, Workflow, text_matches, workflow
|
|
63
|
+
from captain_hook.prompt import Prompt
|
|
64
|
+
from captain_hook.session import SessionSlot, SessionStore, session_state
|
|
65
|
+
from captain_hook.settings import HooksSettings, build_settings
|
|
66
|
+
from captain_hook.signals.nlp import Clause, NlpSignal, Phrase
|
|
67
|
+
from captain_hook.state import HookState, PrimitiveState, WorkflowState, workflow_state
|
|
68
|
+
from captain_hook.tasks import Task, Tasks
|
|
69
|
+
from captain_hook.testing import Allow, Block, FileFixture, InlineTests, Input, Rewrite, TranscriptFixture, Warn
|
|
70
|
+
from captain_hook.types import (
|
|
71
|
+
Action,
|
|
72
|
+
Agent,
|
|
73
|
+
Content,
|
|
74
|
+
CustomCommandLineCondition,
|
|
75
|
+
CustomCondition,
|
|
76
|
+
CustomInputTypeCondition,
|
|
77
|
+
Event,
|
|
78
|
+
FilePath,
|
|
79
|
+
HookResponse,
|
|
80
|
+
HookResult,
|
|
81
|
+
InPlanMode,
|
|
82
|
+
Pattern,
|
|
83
|
+
RanCommand,
|
|
84
|
+
ReadFile,
|
|
85
|
+
Signal,
|
|
86
|
+
Signals,
|
|
87
|
+
SourceEdits,
|
|
88
|
+
TCondition,
|
|
89
|
+
TestFile,
|
|
90
|
+
Tool,
|
|
91
|
+
TouchedFile,
|
|
92
|
+
UsedSkill,
|
|
93
|
+
Waiting,
|
|
94
|
+
)
|
|
95
|
+
from captain_hook.utils import read_json, resolve_binary
|
|
@@ -7,8 +7,6 @@ from fnmatch import fnmatch
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from typing import TYPE_CHECKING, get_args
|
|
9
9
|
|
|
10
|
-
from pydantic_settings import BaseSettings
|
|
11
|
-
|
|
12
10
|
from captain_hook.conditions import matches_conditions
|
|
13
11
|
from captain_hook.types import (
|
|
14
12
|
CustomCondition,
|
|
@@ -23,6 +21,7 @@ if TYPE_CHECKING:
|
|
|
23
21
|
from cc_transcript.activity import UserClassifier
|
|
24
22
|
|
|
25
23
|
from captain_hook.events import BaseHookEvent
|
|
24
|
+
from captain_hook.settings import HooksSettings
|
|
26
25
|
from captain_hook.types import HookResult
|
|
27
26
|
|
|
28
27
|
HookHandler = Callable[["BaseHookEvent"], "HookResult | None"]
|
|
@@ -69,7 +68,7 @@ def validate_handler_signature(fn: HookHandler) -> None:
|
|
|
69
68
|
class State:
|
|
70
69
|
hooks: list[RegisteredHook] = field(default_factory=list)
|
|
71
70
|
gitignore_patterns: list[str] = field(default_factory=list)
|
|
72
|
-
settings:
|
|
71
|
+
settings: HooksSettings | None = None
|
|
73
72
|
classifier: UserClassifier | None = None
|
|
74
73
|
counter: int = field(default=0, repr=False)
|
|
75
74
|
|
|
@@ -254,7 +253,7 @@ def is_planning_agent_skip(spec: HookSpec, evt: BaseHookEvent) -> bool:
|
|
|
254
253
|
return False
|
|
255
254
|
if evt.event not in (Event.SubagentStop | Event.SubagentStart):
|
|
256
255
|
return False
|
|
257
|
-
names =
|
|
256
|
+
names = settings.planning_agents if (settings := _state.settings) else DEFAULT_PLANNING_AGENTS
|
|
258
257
|
return bool(evt.agent_type and evt.agent_type in names)
|
|
259
258
|
|
|
260
259
|
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""The only module that imports ``ast_grep_py``: structural code search and rewriting by language.
|
|
2
|
+
|
|
3
|
+
Everything else in the framework reaches ast-grep through here — the :class:`~captain_hook.types.Pattern`
|
|
4
|
+
condition, the ``ast_grep_rule`` style builders, and the ``rewrite_code`` primitive — so the binding
|
|
5
|
+
lives behind one seam.
|
|
6
|
+
|
|
7
|
+
Patterns are plain ast-grep pattern strings with metavariables: ``print($$$)`` matches a print call
|
|
8
|
+
however its arguments are spelled, ``os.system($CMD)`` captures the argument as ``$CMD``. Rewrites
|
|
9
|
+
reuse ast-grep's ``$VAR`` / ``$$$VAR`` fix syntax. Language ids are the same short keys as
|
|
10
|
+
:data:`~captain_hook.types.LANG_GLOBS` (``"py"``, ``"go"``, ``"ts"``, ...); ast-grep also accepts the
|
|
11
|
+
long names (``"python"``).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from collections.abc import Iterator
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
from captain_hook.types import LANG_GLOBS
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from ast_grep_py import SgNode
|
|
27
|
+
|
|
28
|
+
EXT_TO_LANG: dict[str, str] = {glob.removeprefix("*."): lang for lang, globs in LANG_GLOBS.items() for glob in globs}
|
|
29
|
+
|
|
30
|
+
TEMPLATE_VAR = re.compile(r"\$\$\$([A-Z_][A-Z0-9_]*)|\$([A-Z_][A-Z0-9_]*)")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class Match:
|
|
35
|
+
"""A structural match, located by 1-based line to align with ``Violation`` and changed-line scoping."""
|
|
36
|
+
|
|
37
|
+
line: int
|
|
38
|
+
end_line: int
|
|
39
|
+
text: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def lang_for_path(path: Path) -> str | None:
|
|
43
|
+
"""Infer an ast-grep language id from a file extension, or ``None`` when unsupported."""
|
|
44
|
+
return EXT_TO_LANG.get(path.suffix.removeprefix("."))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def has_metavar(text: str) -> bool:
|
|
48
|
+
"""Whether ``text`` carries an ast-grep metavariable (``$NAME`` or ``$$$NAME``)."""
|
|
49
|
+
return TEMPLATE_VAR.search(text) is not None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse(source: str, lang: str) -> SgNode:
|
|
53
|
+
from ast_grep_py import SgRoot
|
|
54
|
+
|
|
55
|
+
return SgRoot(source, lang).root()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def to_match(node: SgNode) -> Match:
|
|
59
|
+
r = node.range()
|
|
60
|
+
return Match(line=r.start.line + 1, end_line=r.end.line + 1, text=node.text())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def match_key(m: Match) -> str:
|
|
64
|
+
return " ".join(m.text.split())
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def matches(source: str, lang: str, pattern: str) -> bool:
|
|
68
|
+
"""Whether ``pattern`` matches anywhere in ``source`` — the cheap boolean for conditions."""
|
|
69
|
+
return parse(source, lang).find(pattern=pattern) is not None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def find_all(source: str, lang: str, pattern: str) -> Iterator[Match]:
|
|
73
|
+
"""Every structural match of ``pattern`` in ``source``, as 1-based-line :class:`Match` objects."""
|
|
74
|
+
return (to_match(node) for node in parse(source, lang).find_all(pattern=pattern))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def find_introduced(old: str, new: str, lang: str, pattern: str) -> Iterator[Match]:
|
|
78
|
+
"""Matches present in ``new`` whose construct was absent from ``old`` — the diff helper.
|
|
79
|
+
|
|
80
|
+
Identity is the match's whitespace-normalized text, not its range (which shifts as
|
|
81
|
+
surrounding code moves) — so a pre-existing construct is never reported as newly added.
|
|
82
|
+
"""
|
|
83
|
+
before = {match_key(m) for m in find_all(old, lang, pattern)}
|
|
84
|
+
return (m for m in find_all(new, lang, pattern) if match_key(m) not in before)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def rewrite(source: str, lang: str, pattern: str, replace: str) -> str:
|
|
88
|
+
"""Rewrite every ``pattern`` match in ``source`` to ``replace``, an ast-grep fix template.
|
|
89
|
+
|
|
90
|
+
``replace`` uses ast-grep's ``$VAR`` / ``$$$VAR`` fix syntax, each metavariable filled from the
|
|
91
|
+
match it names. Returns ``source`` unchanged when nothing matches.
|
|
92
|
+
"""
|
|
93
|
+
root = parse(source, lang)
|
|
94
|
+
if not (edits := [node.replace(fill_template(node, replace)) for node in root.find_all(pattern=pattern)]):
|
|
95
|
+
return source
|
|
96
|
+
return root.commit_edits(edits)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def capture(source: str, lang: str, pattern: str) -> dict[str, str] | None:
|
|
100
|
+
"""Match ``pattern`` against ``source`` and extract its named metavars, or ``None`` when it doesn't match.
|
|
101
|
+
|
|
102
|
+
Each ``$NAME`` in the pattern maps to the matched node's text; each ``$$$NAME`` maps to the
|
|
103
|
+
original-source span covering its matches, so whitespace is preserved (mirroring :func:`fill_template`).
|
|
104
|
+
A pattern with no metavars that still matches yields an empty dict — present but empty.
|
|
105
|
+
"""
|
|
106
|
+
if (node := parse(source, lang).find(pattern=pattern)) is None:
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
def value(m: re.Match[str]) -> str:
|
|
110
|
+
if (name := m.group(1)) is not None:
|
|
111
|
+
if not (spans := node.get_multiple_matches(name)):
|
|
112
|
+
return ""
|
|
113
|
+
full = node.get_root().root().text()
|
|
114
|
+
return full[spans[0].range().start.index : spans[-1].range().end.index]
|
|
115
|
+
return single.text() if (single := node.get_match(m.group(2))) else ""
|
|
116
|
+
|
|
117
|
+
return {(m.group(1) or m.group(2)): value(m) for m in TEMPLATE_VAR.finditer(pattern)}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def fill_template(node: SgNode, template: str) -> str:
|
|
121
|
+
"""Fill an ast-grep fix ``template`` against one match: ``$NAME`` becomes the metavar's text;
|
|
122
|
+
``$$$NAME`` becomes the original-source span covering its matches, so whitespace is preserved.
|
|
123
|
+
|
|
124
|
+
A ``$NAME`` the pattern never captured is left untouched, so literal ``$VAR`` text in a
|
|
125
|
+
replacement — a shell variable like ``$HOME``, say — passes through unchanged.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def substitute(m: re.Match[str]) -> str:
|
|
129
|
+
if (name := m.group(1)) is not None:
|
|
130
|
+
if not (spans := node.get_multiple_matches(name)):
|
|
131
|
+
return ""
|
|
132
|
+
source = node.get_root().root().text()
|
|
133
|
+
return source[spans[0].range().start.index : spans[-1].range().end.index]
|
|
134
|
+
return single.text() if (single := node.get_match(m.group(2))) else m.group(0)
|
|
135
|
+
|
|
136
|
+
return TEMPLATE_VAR.sub(substitute, template)
|