capt-hook 6.3.0__tar.gz → 6.5.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-6.5.0/PKG-INFO +146 -0
- capt_hook-6.5.0/README.md +98 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/cli.py +3 -1
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/context.py +4 -3
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/durable.py +1 -8
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/loader.py +5 -6
- capt_hook-6.5.0/captain_hook/packs/general/capt-hook.toml +5 -0
- capt_hook-6.5.0/captain_hook/packs/general/detours.py +151 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/general/models.py +303 -16
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/manager.py +2 -2
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/primitives/llm.py +4 -4
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/prompt.py +5 -6
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/session.py +0 -6
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/settings.py +14 -9
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/state.py +0 -2
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/testing/helpers.py +60 -44
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/testing/session_cache.py +3 -1
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/util/model_cache.py +2 -3
- {capt_hook-6.3.0 → capt_hook-6.5.0}/pyproject.toml +2 -2
- capt_hook-6.3.0/PKG-INFO +0 -137
- capt_hook-6.3.0/README.md +0 -89
- capt_hook-6.3.0/captain_hook/packs/general/capt-hook.toml +0 -5
- {capt_hook-6.3.0 → capt_hook-6.5.0}/LICENSE +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/.claude-plugin/plugin.json +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/__init__.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/__main__.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/app.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/ast_grep.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/classifiers/__init__.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/classifiers/conductor.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/classifiers/droid.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/classifiers/native.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/command.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/conditions.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/contexts.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/decisions.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/dispatch.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/events.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/fields.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/file.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/log.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/__init__.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/general/__init__.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/general/_lib.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/general/commands.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/general/docs.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/general/plans.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/general/prompts.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/general/review.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/general/tasks.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/general/tombstones.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/go/__init__.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/go/capt-hook.toml +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/go/testing.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/go/toolchain.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/python/__init__.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/python/capt-hook.toml +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/python/style.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/python/testing.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/python/toolchain.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/steering/__init__.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/steering/capt-hook.toml +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/steering/steering.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/primitives/__init__.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/primitives/commands.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/primitives/lint.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/primitives/nudge.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/primitives/rewrite.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/primitives/workflow.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/py.typed +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/review/__init__.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/review/cli.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/review/dashboard.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/review/fix.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/review/formats.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/review/judge.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/review/pipeline.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/review/repo.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/review/scan.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/review/settings.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/review/store.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/review/sync.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/signals/__init__.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/signals/nlp.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/skills/authoring-hooks/SKILL.md +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/skills/scanning-sessions/SKILL.md +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/style/__init__.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/style/ast_grep.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/style/matchers.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/style/scope.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/style/types.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/tasks.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/testing/__init__.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/testing/types.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/types.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/util/__init__.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/util/fs.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/util/http.py +0 -0
- {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/util/text.py +0 -0
capt_hook-6.5.0/PKG-INFO
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: capt-hook
|
|
3
|
+
Version: 6.5.0
|
|
4
|
+
Summary: Stop repeating yourself to Claude.
|
|
5
|
+
Keywords: claude,claude-code,hooks,llm,agents,guardrails,cli
|
|
6
|
+
Author: Yasyf Mohamedali
|
|
7
|
+
Author-email: Yasyf Mohamedali <yasyfm@gmail.com>
|
|
8
|
+
License-Expression: PolyForm-Noncommercial-1.0.0
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
17
|
+
Classifier: Topic :: Software Development :: Testing
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Dist: cc-transcript>=7.1,<8
|
|
20
|
+
Requires-Dist: pydantic>=2.0
|
|
21
|
+
Requires-Dist: pydantic-settings>=2.0
|
|
22
|
+
Requires-Dist: tree-sitter>=0.24
|
|
23
|
+
Requires-Dist: tree-sitter-bash>=0.23
|
|
24
|
+
Requires-Dist: ast-grep-py>=0.39,<1
|
|
25
|
+
Requires-Dist: funcy>=2.0
|
|
26
|
+
Requires-Dist: spacy>=3.7
|
|
27
|
+
Requires-Dist: click>=8
|
|
28
|
+
Requires-Dist: rich>=13
|
|
29
|
+
Requires-Dist: orjsonl>=1.0
|
|
30
|
+
Requires-Dist: wn>=1.1.0
|
|
31
|
+
Requires-Dist: lazy-object-proxy>=1.12.0
|
|
32
|
+
Requires-Dist: filelock>=3
|
|
33
|
+
Requires-Dist: loguru>=0.7.3
|
|
34
|
+
Requires-Dist: spawnllm>=0.5.2,<0.6
|
|
35
|
+
Requires-Dist: pytest>=8.0 ; extra == 'dev'
|
|
36
|
+
Requires-Dist: pytest-asyncio>=0.24 ; extra == 'dev'
|
|
37
|
+
Requires-Dist: pyright>=1.1 ; extra == 'dev'
|
|
38
|
+
Requires-Dist: pyyaml>=6 ; extra == 'dev'
|
|
39
|
+
Requires-Dist: ruff>=0.8 ; extra == 'dev'
|
|
40
|
+
Requires-Python: >=3.13
|
|
41
|
+
Project-URL: Homepage, https://github.com/yasyf/captain-hook
|
|
42
|
+
Project-URL: Documentation, https://yasyf.github.io/captain-hook/
|
|
43
|
+
Project-URL: Repository, https://github.com/yasyf/captain-hook
|
|
44
|
+
Project-URL: Issues, https://github.com/yasyf/captain-hook/issues
|
|
45
|
+
Project-URL: Changelog, https://github.com/yasyf/captain-hook/blob/main/CHANGELOG.md
|
|
46
|
+
Provides-Extra: dev
|
|
47
|
+
Description-Content-Type: text/markdown
|
|
48
|
+
|
|
49
|
+
# 
|
|
50
|
+
|
|
51
|
+
**Stop repeating yourself to Claude.** captain-hook mines your transcripts for the corrections you keep giving and opens PRs that turn each one into a typed, tested Python hook.
|
|
52
|
+
|
|
53
|
+
[](https://github.com/yasyf/captain-hook/actions/workflows/ci.yml)
|
|
54
|
+
[](https://pypi.org/project/capt-hook/)
|
|
55
|
+
[](https://github.com/yasyf/captain-hook/blob/main/LICENSE)
|
|
56
|
+
|
|
57
|
+
## Get started
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
uvx capt-hook init
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`init` scaffolds `.claude/hooks/`, wires Claude Code's settings, and arms the session reviewer. One `block_command` later, a force-push dies at `PreToolUse` and the hook's inline tests run green:
|
|
64
|
+
|
|
65
|
+
<img src="https://github.com/yasyf/captain-hook/raw/main/docs/assets/demo.png" alt="Terminal running 'uvx capt-hook test' — a hook blocks git push --force and both inline tests pass" width="700">
|
|
66
|
+
|
|
67
|
+
Driving with an agent? Paste this:
|
|
68
|
+
|
|
69
|
+
```text
|
|
70
|
+
/plugin marketplace add yasyf/captain-hook
|
|
71
|
+
/plugin install captain-hook@captain-hook
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
<details>
|
|
75
|
+
<summary>Prefer a prompt over the plugin?</summary>
|
|
76
|
+
|
|
77
|
+
```text
|
|
78
|
+
Run `uvx capt-hook init` in this repo, write one hook that blocks force-pushes,
|
|
79
|
+
and verify it with `uvx capt-hook test`. Read https://yasyf.github.io/captain-hook/
|
|
80
|
+
if you get stuck.
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
</details>
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Use cases
|
|
88
|
+
|
|
89
|
+
### Block force-push and rm -rf before they run
|
|
90
|
+
|
|
91
|
+
One bad Bash call rewrites shared history or eats a directory, and by the time you spot it in the transcript it already ran. Declare the block once, tests inline:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
# .claude/hooks/safety.py
|
|
95
|
+
from captain_hook import Allow, Block, Input, block_command
|
|
96
|
+
|
|
97
|
+
block_command(
|
|
98
|
+
["git", "push", "--force"],
|
|
99
|
+
reason="Force-pushing rewrites shared history",
|
|
100
|
+
hint="Use `git push --force-with-lease` instead",
|
|
101
|
+
tests={
|
|
102
|
+
Input(command="git push --force"): Block(),
|
|
103
|
+
Input(command="git push origin main"): Allow(),
|
|
104
|
+
},
|
|
105
|
+
)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The next `git push --force` never executes: the agent sees `BLOCKED: Force-pushing rewrites shared history` plus the hint, and reaches for `--force-with-lease` instead. `rm -rf` is one more `block_command` away.
|
|
109
|
+
|
|
110
|
+
### Turn repeated corrections into rules Claude can't forget
|
|
111
|
+
|
|
112
|
+
You've typed "use uv, not pip" in a dozen sessions, and session thirteen makes the same mistake. After `init`, the session reviewer reads each transcript as the session ends, keeps the corrections that are standing rules, and — once a pattern proves itself across sessions — opens a PR that codifies it as a hook. Watch the pipeline:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
uvx capt-hook status
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The dashboard lists every correction it's tracking, staged from first sighting to open PR. You review the PR like any other; merged hooks enforce the rule from then on.
|
|
119
|
+
|
|
120
|
+
### Gate "done" until the tests actually pass
|
|
121
|
+
|
|
122
|
+
The agent declares victory while the suite is red. A Stop gate holds the line:
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
# .claude/hooks/quality.py
|
|
126
|
+
from captain_hook import RanCommand, TouchedFile, gate
|
|
127
|
+
|
|
128
|
+
gate(
|
|
129
|
+
"You edited Python files but never ran the tests. Run `uv run pytest` before finishing.",
|
|
130
|
+
only_if=[TouchedFile("**/*.py")],
|
|
131
|
+
skip_if=[RanCommand(r"\bpytest\b")],
|
|
132
|
+
)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The agent can't end the turn until a pytest run shows up in the transcript, and the gate stands down on its own once one does.
|
|
136
|
+
|
|
137
|
+
## More in the docs
|
|
138
|
+
|
|
139
|
+
- **Session reviewer** — the full corrections lifecycle, from transcript to merged hook PR — [guide](https://yasyf.github.io/captain-hook/docs/guide/session-reviewer.html)
|
|
140
|
+
- **Conditions** — typed filters over tools, files, commands, and transcript history — [guide](https://yasyf.github.io/captain-hook/docs/guide/conditions.html)
|
|
141
|
+
- **LLM hooks** — gate on a model's verdict when a regex can't decide — [guide](https://yasyf.github.io/captain-hook/docs/guide/llm-hooks.html)
|
|
142
|
+
- **Workflows** — multi-step Stop gates with artifact checks and checklists — [guide](https://yasyf.github.io/captain-hook/docs/guide/workflows.html)
|
|
143
|
+
- **Packs** — the shipped `general`, `python`, and `go` hook packs — [guide](https://yasyf.github.io/captain-hook/docs/guide/packs.html)
|
|
144
|
+
- **Testing** — run `uvx capt-hook test --json` in CI so a regressed hook fails the build — [guide](https://yasyf.github.io/captain-hook/docs/guide/testing.html)
|
|
145
|
+
|
|
146
|
+
Read the [docs](https://yasyf.github.io/captain-hook/) for the full guide. Licensed under [PolyForm Noncommercial 1.0.0](https://github.com/yasyf/captain-hook/blob/main/LICENSE), free for noncommercial use.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# 
|
|
2
|
+
|
|
3
|
+
**Stop repeating yourself to Claude.** captain-hook mines your transcripts for the corrections you keep giving and opens PRs that turn each one into a typed, tested Python hook.
|
|
4
|
+
|
|
5
|
+
[](https://github.com/yasyf/captain-hook/actions/workflows/ci.yml)
|
|
6
|
+
[](https://pypi.org/project/capt-hook/)
|
|
7
|
+
[](https://github.com/yasyf/captain-hook/blob/main/LICENSE)
|
|
8
|
+
|
|
9
|
+
## Get started
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
uvx capt-hook init
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`init` scaffolds `.claude/hooks/`, wires Claude Code's settings, and arms the session reviewer. One `block_command` later, a force-push dies at `PreToolUse` and the hook's inline tests run green:
|
|
16
|
+
|
|
17
|
+
<img src="https://github.com/yasyf/captain-hook/raw/main/docs/assets/demo.png" alt="Terminal running 'uvx capt-hook test' — a hook blocks git push --force and both inline tests pass" width="700">
|
|
18
|
+
|
|
19
|
+
Driving with an agent? Paste this:
|
|
20
|
+
|
|
21
|
+
```text
|
|
22
|
+
/plugin marketplace add yasyf/captain-hook
|
|
23
|
+
/plugin install captain-hook@captain-hook
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
<details>
|
|
27
|
+
<summary>Prefer a prompt over the plugin?</summary>
|
|
28
|
+
|
|
29
|
+
```text
|
|
30
|
+
Run `uvx capt-hook init` in this repo, write one hook that blocks force-pushes,
|
|
31
|
+
and verify it with `uvx capt-hook test`. Read https://yasyf.github.io/captain-hook/
|
|
32
|
+
if you get stuck.
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
</details>
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Use cases
|
|
40
|
+
|
|
41
|
+
### Block force-push and rm -rf before they run
|
|
42
|
+
|
|
43
|
+
One bad Bash call rewrites shared history or eats a directory, and by the time you spot it in the transcript it already ran. Declare the block once, tests inline:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
# .claude/hooks/safety.py
|
|
47
|
+
from captain_hook import Allow, Block, Input, block_command
|
|
48
|
+
|
|
49
|
+
block_command(
|
|
50
|
+
["git", "push", "--force"],
|
|
51
|
+
reason="Force-pushing rewrites shared history",
|
|
52
|
+
hint="Use `git push --force-with-lease` instead",
|
|
53
|
+
tests={
|
|
54
|
+
Input(command="git push --force"): Block(),
|
|
55
|
+
Input(command="git push origin main"): Allow(),
|
|
56
|
+
},
|
|
57
|
+
)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The next `git push --force` never executes: the agent sees `BLOCKED: Force-pushing rewrites shared history` plus the hint, and reaches for `--force-with-lease` instead. `rm -rf` is one more `block_command` away.
|
|
61
|
+
|
|
62
|
+
### Turn repeated corrections into rules Claude can't forget
|
|
63
|
+
|
|
64
|
+
You've typed "use uv, not pip" in a dozen sessions, and session thirteen makes the same mistake. After `init`, the session reviewer reads each transcript as the session ends, keeps the corrections that are standing rules, and — once a pattern proves itself across sessions — opens a PR that codifies it as a hook. Watch the pipeline:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
uvx capt-hook status
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The dashboard lists every correction it's tracking, staged from first sighting to open PR. You review the PR like any other; merged hooks enforce the rule from then on.
|
|
71
|
+
|
|
72
|
+
### Gate "done" until the tests actually pass
|
|
73
|
+
|
|
74
|
+
The agent declares victory while the suite is red. A Stop gate holds the line:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
# .claude/hooks/quality.py
|
|
78
|
+
from captain_hook import RanCommand, TouchedFile, gate
|
|
79
|
+
|
|
80
|
+
gate(
|
|
81
|
+
"You edited Python files but never ran the tests. Run `uv run pytest` before finishing.",
|
|
82
|
+
only_if=[TouchedFile("**/*.py")],
|
|
83
|
+
skip_if=[RanCommand(r"\bpytest\b")],
|
|
84
|
+
)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The agent can't end the turn until a pytest run shows up in the transcript, and the gate stands down on its own once one does.
|
|
88
|
+
|
|
89
|
+
## More in the docs
|
|
90
|
+
|
|
91
|
+
- **Session reviewer** — the full corrections lifecycle, from transcript to merged hook PR — [guide](https://yasyf.github.io/captain-hook/docs/guide/session-reviewer.html)
|
|
92
|
+
- **Conditions** — typed filters over tools, files, commands, and transcript history — [guide](https://yasyf.github.io/captain-hook/docs/guide/conditions.html)
|
|
93
|
+
- **LLM hooks** — gate on a model's verdict when a regex can't decide — [guide](https://yasyf.github.io/captain-hook/docs/guide/llm-hooks.html)
|
|
94
|
+
- **Workflows** — multi-step Stop gates with artifact checks and checklists — [guide](https://yasyf.github.io/captain-hook/docs/guide/workflows.html)
|
|
95
|
+
- **Packs** — the shipped `general`, `python`, and `go` hook packs — [guide](https://yasyf.github.io/captain-hook/docs/guide/packs.html)
|
|
96
|
+
- **Testing** — run `uvx capt-hook test --json` in CI so a regressed hook fails the build — [guide](https://yasyf.github.io/captain-hook/docs/guide/testing.html)
|
|
97
|
+
|
|
98
|
+
Read the [docs](https://yasyf.github.io/captain-hook/) for the full guide. Licensed under [PolyForm Noncommercial 1.0.0](https://github.com/yasyf/captain-hook/blob/main/LICENSE), free for noncommercial use.
|
|
@@ -501,7 +501,9 @@ def run_tests(json_output: bool = False) -> None:
|
|
|
501
501
|
@click.pass_context
|
|
502
502
|
def cli(ctx: click.Context, hooks: str | None, root_path: str | None) -> None:
|
|
503
503
|
"""Captain Hook — declarative hook framework for Claude Code lifecycle events."""
|
|
504
|
-
|
|
504
|
+
from captain_hook.settings import resolve_project_dir
|
|
505
|
+
|
|
506
|
+
root = Path(root_path) if root_path else Path(p) if (p := resolve_project_dir()) else Path.cwd()
|
|
505
507
|
ctx.obj = CliState(root=root, hooks=hooks or str(root / ".claude" / "hooks"))
|
|
506
508
|
|
|
507
509
|
|
|
@@ -21,6 +21,7 @@ from spawnllm.proc import run_cli
|
|
|
21
21
|
from captain_hook.classifiers import detect
|
|
22
22
|
from captain_hook.prompt import Prompt
|
|
23
23
|
from captain_hook.session import SessionStore
|
|
24
|
+
from captain_hook.settings import resolve_project_dir
|
|
24
25
|
|
|
25
26
|
if TYPE_CHECKING:
|
|
26
27
|
from collections.abc import Sequence
|
|
@@ -78,7 +79,7 @@ def lift_session(events: Sequence[TranscriptEvent], *, path: Path | None = None)
|
|
|
78
79
|
from captain_hook.app import _state
|
|
79
80
|
|
|
80
81
|
classifier = _state.classifier or detect(
|
|
81
|
-
cwd=
|
|
82
|
+
cwd=resolve_project_dir(),
|
|
82
83
|
transcript_path=str(path) if path else None,
|
|
83
84
|
events=events,
|
|
84
85
|
)
|
|
@@ -185,7 +186,7 @@ class HookContext:
|
|
|
185
186
|
input=input,
|
|
186
187
|
timeout=timeout,
|
|
187
188
|
env=os.environ | (env or {}),
|
|
188
|
-
cwd=
|
|
189
|
+
cwd=resolve_project_dir(),
|
|
189
190
|
)
|
|
190
191
|
except (OSError, subprocess.SubprocessError):
|
|
191
192
|
if throw:
|
|
@@ -309,7 +310,7 @@ class HookContext:
|
|
|
309
310
|
) -> str | BaseModel:
|
|
310
311
|
diff_text = self.diff("uncommitted" if diff is True else diff) if diff else None
|
|
311
312
|
prompt = self.assemble_prompt(template, args, kwargs, transcript=transcript, diff_text=diff_text)
|
|
312
|
-
cwd =
|
|
313
|
+
cwd = resolve_project_dir()
|
|
313
314
|
if response_model is not None:
|
|
314
315
|
return extract_sync(
|
|
315
316
|
prompt, response_model, specialty=specialty, model=model, agent=agent, cwd=cwd, timeout=timeout
|
|
@@ -26,13 +26,11 @@ if TYPE_CHECKING:
|
|
|
26
26
|
|
|
27
27
|
Scope = Literal["project", "global"]
|
|
28
28
|
|
|
29
|
-
DURABLE_ROOT_OVERRIDE: list[Path] = []
|
|
30
|
-
|
|
31
29
|
|
|
32
30
|
def durable_root() -> Path:
|
|
33
31
|
from captain_hook.settings import resolve_state_dir
|
|
34
32
|
|
|
35
|
-
return
|
|
33
|
+
return resolve_state_dir() / "hooks" / "durable"
|
|
36
34
|
|
|
37
35
|
|
|
38
36
|
def project_key(repo_root: Path) -> str:
|
|
@@ -98,11 +96,6 @@ class DurableStore:
|
|
|
98
96
|
if model not in cls.TRACKED:
|
|
99
97
|
cls.TRACKED.append(model)
|
|
100
98
|
|
|
101
|
-
@classmethod
|
|
102
|
-
def untrack(cls, model: type[BaseModel]) -> None:
|
|
103
|
-
if model in cls.TRACKED:
|
|
104
|
-
cls.TRACKED.remove(model)
|
|
105
|
-
|
|
106
99
|
@classmethod
|
|
107
100
|
def tracked_models(cls) -> Sequence[type[BaseModel]]:
|
|
108
101
|
return tuple(cls.TRACKED)
|
|
@@ -13,7 +13,7 @@ from typing import TYPE_CHECKING
|
|
|
13
13
|
|
|
14
14
|
from loguru import logger
|
|
15
15
|
|
|
16
|
-
from captain_hook.app import
|
|
16
|
+
from captain_hook.app import _state, on
|
|
17
17
|
from captain_hook.settings import build_settings
|
|
18
18
|
from captain_hook.types import Event
|
|
19
19
|
|
|
@@ -47,8 +47,7 @@ def import_or_reload(fqn: str, fresh_this_pass: set[str]) -> ModuleType:
|
|
|
47
47
|
return mod
|
|
48
48
|
|
|
49
49
|
|
|
50
|
-
def discover_hooks(hooks_dir: str | Path
|
|
51
|
-
target = state or _state
|
|
50
|
+
def discover_hooks(hooks_dir: str | Path) -> None:
|
|
52
51
|
hooks_path = Path(hooks_dir).resolve()
|
|
53
52
|
if str(hooks_path.parent) not in sys.path:
|
|
54
53
|
sys.path.insert(0, str(hooks_path.parent))
|
|
@@ -60,9 +59,9 @@ def discover_hooks(hooks_dir: str | Path, state: State | None = None) -> None:
|
|
|
60
59
|
|
|
61
60
|
if CONF_MODULE in top_level:
|
|
62
61
|
conf_module = import_or_reload(f"{pkg}.{CONF_MODULE}", fresh_this_pass)
|
|
63
|
-
|
|
62
|
+
_state.settings = build_settings(conf_module)
|
|
64
63
|
if classifier := getattr(conf_module, "classifier", None):
|
|
65
|
-
|
|
64
|
+
_state.classifier = classifier
|
|
66
65
|
|
|
67
66
|
all_modules = {
|
|
68
67
|
info.name
|
|
@@ -80,7 +79,7 @@ def discover_hooks(hooks_dir: str | Path, state: State | None = None) -> None:
|
|
|
80
79
|
import_or_reload(fqn, fresh_this_pass)
|
|
81
80
|
except Exception as exc:
|
|
82
81
|
logger.bind(module=fqn).opt(exception=True).warning("skipped unloadable hook module")
|
|
83
|
-
|
|
82
|
+
_state.load_errors.append((fqn, exc))
|
|
84
83
|
|
|
85
84
|
|
|
86
85
|
def ensure_pack_package(fqn: str, search_paths: list[str]) -> ModuleType:
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from captain_hook import (
|
|
4
|
+
Allow,
|
|
5
|
+
Event,
|
|
6
|
+
Input,
|
|
7
|
+
Signal,
|
|
8
|
+
Signals,
|
|
9
|
+
Tool,
|
|
10
|
+
Warn,
|
|
11
|
+
llm_nudge,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
llm_nudge(
|
|
15
|
+
"""You are a senior engineer watching another engineer ("the agent") mid-task. You are
|
|
16
|
+
running in agent mode in the project's working directory, with read tools. Your one job:
|
|
17
|
+
decide whether the agent has veered onto an UNREQUESTED DETOUR — side work nobody asked
|
|
18
|
+
for — without checking in first.
|
|
19
|
+
|
|
20
|
+
Read first, judge second:
|
|
21
|
+
- The session transcript is rendered above inside `<transcript path="...">`; long content is
|
|
22
|
+
clipped (you'll see `…(+Nch)` markers). From the full history (prefer `cc-transcript
|
|
23
|
+
show`/`grep`; else read the file) establish: (1) what was actually ASKED — the user's
|
|
24
|
+
original request plus any later redirections or standing permissions — and (2) what the
|
|
25
|
+
agent is doing RIGHT NOW — the just-run tool call and the last few assistant messages.
|
|
26
|
+
|
|
27
|
+
Discriminator: the current work is either (a) the requested task, (b) a necessary
|
|
28
|
+
prerequisite of it (the task cannot land or be verified without it), or (c) authorized —
|
|
29
|
+
the user said some form of "also…", "while you're there…", "fix anything you find", or it
|
|
30
|
+
is a small stewardship fix inside code the task already touches. Any of those -> fire=false.
|
|
31
|
+
Otherwise — the agent noticed something adjacent and started acting on it without surfacing
|
|
32
|
+
it — fire=true.
|
|
33
|
+
|
|
34
|
+
Detour tells (lean fire=true): "while I'm here" / "might as well" / "let me also" followed
|
|
35
|
+
by edits to files the task doesn't need; fixing or refactoring code the request never
|
|
36
|
+
mentioned and the task doesn't depend on; a cleanup sweep starting mid-task; chasing a side
|
|
37
|
+
mystery at length while the requested work sits unfinished.
|
|
38
|
+
|
|
39
|
+
Do NOT fire when: the side work blocks the task (a broken build or failing test the change
|
|
40
|
+
trips over); it's a small stewardship fix in a file already being edited for the task; the
|
|
41
|
+
user pre-authorized it; the agent is gathering context it needs; or the agent already
|
|
42
|
+
surfaced the discovery and offered options instead of acting.
|
|
43
|
+
|
|
44
|
+
<examples>
|
|
45
|
+
<example fire="true">
|
|
46
|
+
Asked: "rename the config flag". Agent: "While I'm here, the retry logic in client.py looks
|
|
47
|
+
wrong — let me fix that too", then edits client.py.
|
|
48
|
+
Unrequested fix in a file the rename never touches.
|
|
49
|
+
</example>
|
|
50
|
+
<example fire="true">
|
|
51
|
+
Asked: "add a --json flag". Agent: "I also noticed the error handling is inconsistent
|
|
52
|
+
across commands; I'll clean that up as well", then starts a multi-file sweep.
|
|
53
|
+
Scope expansion nobody asked for, no options offered.
|
|
54
|
+
</example>
|
|
55
|
+
<example fire="false">
|
|
56
|
+
Asked: "fix the failing test". Agent: "The fixture helper it calls has the actual bug —
|
|
57
|
+
fixing that first."
|
|
58
|
+
A prerequisite: the requested fix cannot land without it.
|
|
59
|
+
</example>
|
|
60
|
+
<example fire="false">
|
|
61
|
+
User said "clean up anything you find along the way"; agent fixes a stale docstring in a
|
|
62
|
+
file it was already editing.
|
|
63
|
+
Pre-authorized stewardship.
|
|
64
|
+
</example>
|
|
65
|
+
<example fire="false">
|
|
66
|
+
Agent: "I noticed X while working on Y — options: (1) finish Y and file X as a follow-up,
|
|
67
|
+
(2) fix X now, (3) ignore it. Which?"
|
|
68
|
+
Already surfacing options instead of acting.
|
|
69
|
+
</example>
|
|
70
|
+
</examples>
|
|
71
|
+
|
|
72
|
+
When uncertain, return fire=false. A missed detour costs one review comment; a false alarm
|
|
73
|
+
on legitimate work teaches the agent to ignore this nudge. Fire only when the current action
|
|
74
|
+
is clearly outside both the request and its prerequisites. Put your reasoning (under 50
|
|
75
|
+
words, naming the detour and the requested task) in `reasoning`.""",
|
|
76
|
+
message=lambda r: (
|
|
77
|
+
f"This looks like a detour — side work nobody asked for. {r.reasoning} "
|
|
78
|
+
"Stop and check in before acting: say what you noticed, then propose 2-4 concrete "
|
|
79
|
+
"options (finish the task and file this as a follow-up; pause and fix it now; ignore "
|
|
80
|
+
"it) and let the user pick — or, if you are a delegated agent, stop and return early "
|
|
81
|
+
"with findings plus options for your orchestrator instead of improvising. "
|
|
82
|
+
"See: AGENTS.md § Ask Before Assuming."
|
|
83
|
+
),
|
|
84
|
+
only_if=[Tool("Edit|Write|MultiEdit|NotebookEdit|Bash")],
|
|
85
|
+
events=Event.PostToolUse,
|
|
86
|
+
signals=Signals(
|
|
87
|
+
[
|
|
88
|
+
Signal(pattern=r"(?i)\bwhile (?:I'm|I am|we're|we are) (?:here|at it|in (?:here|there))\b", weight=2),
|
|
89
|
+
Signal(pattern=r"(?i)\bmight as well\b", weight=2),
|
|
90
|
+
Signal(pattern=r"(?i)\b(?:let me|I'll|I will) also\b", weight=2),
|
|
91
|
+
Signal(pattern=r"(?i)\bas a bonus\b", weight=2),
|
|
92
|
+
Signal(pattern=r"(?i)\bI (?:also )?noticed\b", weight=1),
|
|
93
|
+
Signal(pattern=r"(?i)\b(?:unrelated|a side note|tangent)\b", weight=1),
|
|
94
|
+
Signal(pattern=r"(?i)\bone more thing\b", weight=1),
|
|
95
|
+
Signal(pattern=r"(?i)\bquick(?:ly)? (?:fix|clean|tidy|refactor)\w*\b", weight=1),
|
|
96
|
+
],
|
|
97
|
+
threshold=2,
|
|
98
|
+
window=8,
|
|
99
|
+
),
|
|
100
|
+
tests={
|
|
101
|
+
Input(
|
|
102
|
+
file="client.py",
|
|
103
|
+
content="retry = 3\n",
|
|
104
|
+
transcript=[
|
|
105
|
+
{
|
|
106
|
+
"type": "assistant",
|
|
107
|
+
"message": {
|
|
108
|
+
"content": [
|
|
109
|
+
{"type": "text", "text": "While I'm here, the retry logic looks wrong — fixing it too."}
|
|
110
|
+
]
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
],
|
|
114
|
+
): Warn(pattern="detour"),
|
|
115
|
+
Input(
|
|
116
|
+
command="./scripts/cleanup.sh",
|
|
117
|
+
transcript=[
|
|
118
|
+
{
|
|
119
|
+
"type": "assistant",
|
|
120
|
+
"message": {
|
|
121
|
+
"content": [
|
|
122
|
+
{"type": "text", "text": "I also noticed stale artifacts. One more thing to clean up."}
|
|
123
|
+
]
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
],
|
|
127
|
+
): Warn(pattern="options"),
|
|
128
|
+
Input(
|
|
129
|
+
file="flags.py",
|
|
130
|
+
content="json_flag = True\n",
|
|
131
|
+
transcript=[
|
|
132
|
+
{
|
|
133
|
+
"type": "assistant",
|
|
134
|
+
"message": {"content": [{"type": "text", "text": "Implementing the requested --json flag now."}]},
|
|
135
|
+
}
|
|
136
|
+
],
|
|
137
|
+
): Allow(),
|
|
138
|
+
Input(
|
|
139
|
+
tool="Read",
|
|
140
|
+
file="client.py",
|
|
141
|
+
transcript=[
|
|
142
|
+
{
|
|
143
|
+
"type": "assistant",
|
|
144
|
+
"message": {
|
|
145
|
+
"content": [{"type": "text", "text": "While I'm here, might as well look at the retry logic."}]
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
],
|
|
149
|
+
): Allow(),
|
|
150
|
+
},
|
|
151
|
+
)
|