capt-hook 3.3.2__tar.gz → 3.4.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-3.4.0/PKG-INFO +136 -0
- capt_hook-3.4.0/README.md +89 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/cli.py +12 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/review/cli.py +11 -0
- capt_hook-3.4.0/captain_hook/review/dashboard.py +208 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/review/store.py +112 -16
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/review/sync.py +5 -2
- {capt_hook-3.3.2 → capt_hook-3.4.0}/pyproject.toml +2 -1
- capt_hook-3.3.2/PKG-INFO +0 -152
- capt_hook-3.3.2/README.md +0 -106
- {capt_hook-3.3.2 → capt_hook-3.4.0}/LICENSE +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/.claude-plugin/plugin.json +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/__init__.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/__main__.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/app.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/classifiers/__init__.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/classifiers/conductor.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/classifiers/droid.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/classifiers/native.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/command.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/conditions.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/context.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/decisions.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/dispatch.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/events.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/file.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/llm/__init__.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/loader.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/log.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/packs/__init__.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/packs/general/capt-hook.toml +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/packs/general/commands.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/packs/general/docs.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/packs/general/plans.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/packs/general/prompts.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/packs/general/review.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/packs/general/stewardship.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/packs/general/tasks.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/packs/manager.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/packs/python/capt-hook.toml +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/packs/python/style.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/packs/python/testing.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/packs/python/toolchain.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/primitives/__init__.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/primitives/commands.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/primitives/lint.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/primitives/llm.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/primitives/nudge.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/primitives/workflow.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/prompt.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/py.typed +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/review/__init__.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/review/fix.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/review/formats.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/review/judge.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/review/pipeline.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/review/repo.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/review/scan.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/review/settings.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/session.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/settings.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/signals/__init__.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/signals/nlp.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/skills/authoring-hooks/SKILL.md +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/skills/scanning-sessions/SKILL.md +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/state.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/style/__init__.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/style/matchers.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/style/scope.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/style/types.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/tasks.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/testing/__init__.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/testing/helpers.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/testing/session_cache.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/testing/types.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/tests/__init__.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/tests/helpers.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/types.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/util/__init__.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/util/model_cache.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/utils.py +0 -0
capt_hook-3.4.0/PKG-INFO
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: capt-hook
|
|
3
|
+
Version: 3.4.0
|
|
4
|
+
Summary: Declarative hook framework for Claude Code
|
|
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>=3.2,<4
|
|
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: funcy>=2.0
|
|
25
|
+
Requires-Dist: spacy>=3.7
|
|
26
|
+
Requires-Dist: click>=8
|
|
27
|
+
Requires-Dist: rich>=13
|
|
28
|
+
Requires-Dist: orjsonl>=1.0
|
|
29
|
+
Requires-Dist: wn>=1.1.0
|
|
30
|
+
Requires-Dist: lazy-object-proxy>=1.12.0
|
|
31
|
+
Requires-Dist: filelock>=3
|
|
32
|
+
Requires-Dist: loguru>=0.7.3
|
|
33
|
+
Requires-Dist: spawnllm>=0.1.3
|
|
34
|
+
Requires-Dist: pytest>=8.0 ; extra == 'dev'
|
|
35
|
+
Requires-Dist: pytest-asyncio>=0.24 ; extra == 'dev'
|
|
36
|
+
Requires-Dist: pyright>=1.1 ; extra == 'dev'
|
|
37
|
+
Requires-Dist: pyyaml>=6 ; extra == 'dev'
|
|
38
|
+
Requires-Dist: ruff>=0.8 ; extra == 'dev'
|
|
39
|
+
Requires-Python: >=3.13
|
|
40
|
+
Project-URL: Homepage, https://github.com/yasyf/captain-hook
|
|
41
|
+
Project-URL: Documentation, https://yasyf.github.io/captain-hook/
|
|
42
|
+
Project-URL: Repository, https://github.com/yasyf/captain-hook
|
|
43
|
+
Project-URL: Issues, https://github.com/yasyf/captain-hook/issues
|
|
44
|
+
Project-URL: Changelog, https://github.com/yasyf/captain-hook/blob/main/CHANGELOG.md
|
|
45
|
+
Provides-Extra: dev
|
|
46
|
+
Description-Content-Type: text/markdown
|
|
47
|
+
|
|
48
|
+
# captain-hook
|
|
49
|
+
|
|
50
|
+

|
|
51
|
+
|
|
52
|
+
[](https://pypi.org/project/capt-hook/)
|
|
53
|
+
[](https://pypi.org/project/capt-hook/)
|
|
54
|
+
[](https://yasyf.github.io/captain-hook/)
|
|
55
|
+
[](https://github.com/yasyf/captain-hook/blob/main/LICENSE)
|
|
56
|
+
|
|
57
|
+
Guardrails for Claude Code, written as typed, testable data — and learned from the corrections you give Claude.
|
|
58
|
+
|
|
59
|
+
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.
|
|
60
|
+
|
|
61
|
+
## Install
|
|
62
|
+
|
|
63
|
+
captain-hook needs no install — it runs through [uvx](https://docs.astral.sh/uv/). From your project root:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
uvx capt-hook init
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`init` scaffolds `.claude/hooks/`, wires Claude Code's settings, installs the bundled skills, and arms the [session reviewer](#it-learns-from-your-corrections). Or install the plugin and let Claude do it. Run `/plugin marketplace add yasyf/captain-hook`, then ask Claude to "set up captain hook".
|
|
70
|
+
|
|
71
|
+
## Your first hook
|
|
72
|
+
|
|
73
|
+
A hook is an event, some conditions, and an action. This one stops the agent from finishing a UI change it never looked at:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
# .claude/hooks/visual_review.py
|
|
77
|
+
from captain_hook import gate, TouchedFile, UsedSkill
|
|
78
|
+
|
|
79
|
+
gate(
|
|
80
|
+
"You edited UI files. Open them with agent-browser and verify they render before finishing.",
|
|
81
|
+
only_if=[TouchedFile("**/src/routes/**", "**/src/components/**")],
|
|
82
|
+
skip_if=[UsedSkill("agent-browser")],
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`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.
|
|
87
|
+
|
|
88
|
+
## It learns from your corrections
|
|
89
|
+
|
|
90
|
+
Most hooks you'll never write by hand.
|
|
91
|
+
|
|
92
|
+
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.
|
|
93
|
+
|
|
94
|
+
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.
|
|
95
|
+
|
|
96
|
+
## Tested like code
|
|
97
|
+
|
|
98
|
+
Every deterministic hook carries inline tests, so a broken hook fails like broken code:
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
# .claude/hooks/safety.py
|
|
102
|
+
from captain_hook import Allow, Block, Input, block_command
|
|
103
|
+
|
|
104
|
+
block_command(
|
|
105
|
+
["git", "stash"],
|
|
106
|
+
reason="Use the team's VCS workflow for shelving changes",
|
|
107
|
+
hint="Commit a WIP change instead of stashing",
|
|
108
|
+
tests={
|
|
109
|
+
Input(command="git stash"): Block(),
|
|
110
|
+
Input(command="git status"): Allow(),
|
|
111
|
+
},
|
|
112
|
+
)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Run them from your project root, where `--hooks` defaults to `.claude/hooks`:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
uvx capt-hook test
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Wire that into CI and you catch a broken hook the way you catch broken code.
|
|
122
|
+
|
|
123
|
+
## What it's for
|
|
124
|
+
|
|
125
|
+
- Block footguns before they run on `PreToolUse`: force-push, `rm -rf`, package-manager traps.
|
|
126
|
+
- Steer the agent with feedback that fires on the patterns it actually emits: repeated failures, weakened tests, missed conventions.
|
|
127
|
+
- 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.
|
|
128
|
+
- Keep all of it testable; every hook ships with inline tests that run in CI.
|
|
129
|
+
|
|
130
|
+
## Docs
|
|
131
|
+
|
|
132
|
+
[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/).
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
Licensed under [PolyForm Noncommercial 1.0.0](LICENSE), free for noncommercial use.
|
|
@@ -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, installs the bundled skills, and arms the [session reviewer](#it-learns-from-your-corrections). Or install the plugin and let Claude do it. 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.
|
|
@@ -527,6 +527,18 @@ def logs(session: str | None, tail: int | None) -> None:
|
|
|
527
527
|
show_logs(session=session, tail=tail)
|
|
528
528
|
|
|
529
529
|
|
|
530
|
+
@cli.command()
|
|
531
|
+
@click.option("--repo", "repo_", default=None, help="Repo key (default: the current repo)")
|
|
532
|
+
@click.option("--sync/--no-sync", default=True, help="Refresh open PR states from GitHub in the background")
|
|
533
|
+
@click.pass_obj
|
|
534
|
+
def status(state: CliState, repo_: str | None, sync: bool) -> None:
|
|
535
|
+
"""Show the corrections the session reviewer is tracking and the hook PRs they would open."""
|
|
536
|
+
from captain_hook.review.cli import resolve_repo
|
|
537
|
+
from captain_hook.review.dashboard import status_command
|
|
538
|
+
|
|
539
|
+
status_command(resolve_repo(repo_, state.root), sync=sync)
|
|
540
|
+
|
|
541
|
+
|
|
530
542
|
@cli.group()
|
|
531
543
|
def skills() -> None:
|
|
532
544
|
"""Manage the bundled Claude Code skills."""
|
|
@@ -198,6 +198,17 @@ def triage(limit: int | None) -> None:
|
|
|
198
198
|
click.echo(f"judged {report.judged}, failed {report.failed}, pending {report.pending}")
|
|
199
199
|
|
|
200
200
|
|
|
201
|
+
@review.command(name="status")
|
|
202
|
+
@click.option("--repo", "repo_", default=None, help="Repo key (default: the current repo)")
|
|
203
|
+
@click.option("--sync/--no-sync", default=True, help="Refresh open PR states from GitHub in the background")
|
|
204
|
+
@click.pass_obj
|
|
205
|
+
def status(state: CliState, repo_: str | None, sync: bool) -> None:
|
|
206
|
+
"""Show the tracked corrections, their progress toward a PR, and open PR status."""
|
|
207
|
+
from captain_hook.review.dashboard import status_command
|
|
208
|
+
|
|
209
|
+
status_command(resolve_repo(repo_, state.root), sync=sync)
|
|
210
|
+
|
|
211
|
+
|
|
201
212
|
@review.command(name="list")
|
|
202
213
|
@click.option("--repo", "repo_", default=None, help="Repo key (default: the current repo)")
|
|
203
214
|
@click.pass_obj
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""The ``capt-hook status`` dashboard: the corrections lifecycle, rendered.
|
|
2
|
+
|
|
3
|
+
Reads the reviewer's :class:`~captain_hook.review.store.ReviewStore` and renders
|
|
4
|
+
every candidate the reviewer tracks, bucketed by lifecycle stage — watching
|
|
5
|
+
(building toward the bar), eligible (a PR opens next session), PR open, and the
|
|
6
|
+
merged/closed/stale outcomes. Each row shows kind-aware progress toward its PR
|
|
7
|
+
thresholds and the one-sentence summary of what its PR would do. Open PRs are
|
|
8
|
+
shown from the last-synced state first, then refreshed against GitHub in the
|
|
9
|
+
background so the view appears instantly and updates when ``gh`` returns.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
from datetime import UTC, datetime
|
|
16
|
+
from enum import StrEnum
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from rich.console import Console, Group
|
|
20
|
+
from rich.spinner import Spinner
|
|
21
|
+
from rich.text import Text
|
|
22
|
+
|
|
23
|
+
from captain_hook.review.store import CandidateKind, CandidateStatus
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from rich.console import RenderableType
|
|
27
|
+
|
|
28
|
+
from captain_hook.review.repo import RepoKey
|
|
29
|
+
from captain_hook.review.settings import ReviewSettings
|
|
30
|
+
from captain_hook.review.store import CandidateView
|
|
31
|
+
|
|
32
|
+
BAR_FILLED = "█"
|
|
33
|
+
BAR_EMPTY = "░"
|
|
34
|
+
DETAIL_WIDTH = 80
|
|
35
|
+
KIND_STYLE = {CandidateKind.CREATE: "cyan", CandidateKind.FIX: "magenta"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Stage(StrEnum):
|
|
39
|
+
"""A candidate's dashboard bucket — its lifecycle status, with watching split by eligibility."""
|
|
40
|
+
|
|
41
|
+
WATCHING = "watching"
|
|
42
|
+
ELIGIBLE = "eligible"
|
|
43
|
+
PR_OPEN = "pr_open"
|
|
44
|
+
ACCEPTED = "accepted"
|
|
45
|
+
REJECTED = "rejected"
|
|
46
|
+
STALE = "stale"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
SECTIONS: tuple[tuple[Stage, str, str, str], ...] = (
|
|
50
|
+
(Stage.WATCHING, "WATCHING", "building toward the bar", "yellow"),
|
|
51
|
+
(Stage.ELIGIBLE, "ELIGIBLE", "a PR opens next session", "green"),
|
|
52
|
+
(Stage.PR_OPEN, "PR OPEN", "pull request awaiting your review", "blue"),
|
|
53
|
+
(Stage.ACCEPTED, "ACCEPTED", "PR merged", "green"),
|
|
54
|
+
(Stage.REJECTED, "REJECTED", "PR closed", "red"),
|
|
55
|
+
(Stage.STALE, "STALE", "PR open too long", "bright_black"),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def stage_of(view: CandidateView) -> Stage:
|
|
60
|
+
"""Buckets a candidate: its status, with ``watching`` split into eligible vs not."""
|
|
61
|
+
match CandidateStatus(str(view.row["status"])):
|
|
62
|
+
case CandidateStatus.PR_OPEN:
|
|
63
|
+
return Stage.PR_OPEN
|
|
64
|
+
case CandidateStatus.ACCEPTED:
|
|
65
|
+
return Stage.ACCEPTED
|
|
66
|
+
case CandidateStatus.REJECTED:
|
|
67
|
+
return Stage.REJECTED
|
|
68
|
+
case CandidateStatus.STALE:
|
|
69
|
+
return Stage.STALE
|
|
70
|
+
case CandidateStatus.WATCHING:
|
|
71
|
+
return Stage.ELIGIBLE if view.eligible else Stage.WATCHING
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def trim(text: str, *, width: int = DETAIL_WIDTH) -> str:
|
|
75
|
+
return flat if len(flat := " ".join(text.split())) <= width else flat[: width - 1] + "…"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def bar(done: int, need: int, *, width: int = 5) -> str:
|
|
79
|
+
cells = min(need, width)
|
|
80
|
+
filled = min(cells, round(done / need * cells)) if need else cells
|
|
81
|
+
return BAR_FILLED * filled + BAR_EMPTY * (cells - filled)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def targets(view: CandidateView, settings: ReviewSettings) -> tuple[tuple[str, int, int], ...]:
|
|
85
|
+
t = view.threshold
|
|
86
|
+
match t.kind:
|
|
87
|
+
case CandidateKind.CREATE:
|
|
88
|
+
return (("sessions", t.sessions, settings.min_sessions), ("days", t.days, settings.min_days))
|
|
89
|
+
case CandidateKind.FIX:
|
|
90
|
+
return (("sessions", t.sessions, settings.min_sessions_fix), ("days", t.days, settings.min_days_fix))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def progress_text(view: CandidateView, settings: ReviewSettings) -> str:
|
|
94
|
+
return " ".join(
|
|
95
|
+
f"{label} {bar(done, need)} {done}/{need}" for label, done, need in targets(view, settings) if need > 0
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def pr_description(view: CandidateView) -> str:
|
|
100
|
+
"""The one-line summary of what this candidate's PR would (or did) do."""
|
|
101
|
+
row = view.row
|
|
102
|
+
detail = trim(view.summary or str(row["sample_text"] or ""))
|
|
103
|
+
match CandidateKind(str(row["candidate_kind"])):
|
|
104
|
+
case CandidateKind.CREATE:
|
|
105
|
+
return f'would add a hook: "{detail}"' if detail else "would add a hook for this correction"
|
|
106
|
+
case CandidateKind.FIX:
|
|
107
|
+
tail = view.summary or (
|
|
108
|
+
f"regression test for {row['misfire_class']}" if row["misfire_class"] else "regression test for the misfire"
|
|
109
|
+
)
|
|
110
|
+
return f"would fix {row['target_hook_name']} ({row['target_source_file']}): {trim(str(tail))}"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def age_days(row: dict[str, object]) -> int | None:
|
|
114
|
+
if not (opened := row["pr_opened_at"]):
|
|
115
|
+
return None
|
|
116
|
+
return (datetime.now(UTC) - datetime.fromisoformat(str(opened))).days
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def pr_link(view: CandidateView) -> str:
|
|
120
|
+
url = str(view.row["pr_url"] or "(no url)")
|
|
121
|
+
return f"{url} · {days}d open" if stage_of(view) is Stage.PR_OPEN and (days := age_days(view.row)) is not None else url
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def lead_detail(view: CandidateView, settings: ReviewSettings) -> str:
|
|
125
|
+
match stage_of(view):
|
|
126
|
+
case Stage.WATCHING:
|
|
127
|
+
return progress_text(view, settings)
|
|
128
|
+
case Stage.ELIGIBLE:
|
|
129
|
+
return f"ready · {progress_text(view, settings)}"
|
|
130
|
+
case _:
|
|
131
|
+
return pr_link(view)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def candidate_block(view: CandidateView, settings: ReviewSettings) -> RenderableType:
|
|
135
|
+
kind = CandidateKind(str(view.row["candidate_kind"]))
|
|
136
|
+
return Group(
|
|
137
|
+
Text.assemble(
|
|
138
|
+
(f" #{view.row['id']}", "bold"),
|
|
139
|
+
" ",
|
|
140
|
+
(kind.value.ljust(6), KIND_STYLE[kind]),
|
|
141
|
+
" ",
|
|
142
|
+
lead_detail(view, settings),
|
|
143
|
+
),
|
|
144
|
+
Text(f" {pr_description(view)}", style="dim"),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def header(repo: RepoKey, views: list[CandidateView], settings: ReviewSettings, *, watching: bool) -> RenderableType:
|
|
149
|
+
open_n = sum(1 for v in views if stage_of(v) is Stage.PR_OPEN)
|
|
150
|
+
line = Text.assemble(
|
|
151
|
+
("captain-hook", "bold"),
|
|
152
|
+
(" · ", "dim"),
|
|
153
|
+
(str(repo), "cyan"),
|
|
154
|
+
" ",
|
|
155
|
+
(f"[{'watching' if watching else 'not watching'}]", "green" if watching else "yellow"),
|
|
156
|
+
" ",
|
|
157
|
+
(f"PR slots {open_n}/{settings.max_open_prs}", "dim"),
|
|
158
|
+
)
|
|
159
|
+
if watching:
|
|
160
|
+
return line
|
|
161
|
+
return Group(line, Text(" run `capt-hook review enable` to start tracking this repo.", style="dim"))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def render(
|
|
165
|
+
views: list[CandidateView], *, repo: RepoKey, settings: ReviewSettings, watching: bool, syncing: bool = False
|
|
166
|
+
) -> RenderableType:
|
|
167
|
+
"""The whole dashboard frame: header, then a section per non-empty lifecycle stage."""
|
|
168
|
+
sections = [
|
|
169
|
+
block
|
|
170
|
+
for stage, title, desc, style in SECTIONS
|
|
171
|
+
if (members := [v for v in views if stage_of(v) is stage])
|
|
172
|
+
for block in (
|
|
173
|
+
Text.assemble((title, f"bold {style}"), (f" {desc}", "dim")),
|
|
174
|
+
*(candidate_block(v, settings) for v in members),
|
|
175
|
+
Text(""),
|
|
176
|
+
)
|
|
177
|
+
]
|
|
178
|
+
empty = [] if views else [Text("No corrections tracked yet — they appear here as you correct Claude.", style="dim")]
|
|
179
|
+
spinner = [Spinner("dots", text=Text("syncing open PRs with GitHub…", style="dim"))] if syncing else []
|
|
180
|
+
return Group(header(repo, views, settings, watching=watching), Text(""), *sections, *empty, *spinner)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
async def run_status(repo: RepoKey, *, sync: bool) -> None:
|
|
184
|
+
"""Renders the dashboard for ``repo``, refreshing open-PR state in the background when ``sync``."""
|
|
185
|
+
from rich.live import Live
|
|
186
|
+
|
|
187
|
+
from captain_hook.review.judge import REVIEW_PROMPT_VERSION
|
|
188
|
+
from captain_hook.review.settings import ReviewSettings
|
|
189
|
+
from captain_hook.review.store import ReviewStore
|
|
190
|
+
from captain_hook.review.sync import sync_open_prs
|
|
191
|
+
|
|
192
|
+
settings = ReviewSettings()
|
|
193
|
+
console = Console()
|
|
194
|
+
async with await ReviewStore.open(settings.db_path) as store:
|
|
195
|
+
watching = await store.watching(repo)
|
|
196
|
+
views = await store.overview(repo, settings=settings, prompt_version=REVIEW_PROMPT_VERSION)
|
|
197
|
+
if not (sync and any(stage_of(v) is Stage.PR_OPEN for v in views)):
|
|
198
|
+
console.print(render(views, repo=repo, settings=settings, watching=watching))
|
|
199
|
+
return
|
|
200
|
+
with Live(render(views, repo=repo, settings=settings, watching=watching, syncing=True), console=console) as live:
|
|
201
|
+
await sync_open_prs(store, repo, settings=settings)
|
|
202
|
+
fresh = await store.overview(repo, settings=settings, prompt_version=REVIEW_PROMPT_VERSION)
|
|
203
|
+
live.update(render(fresh, repo=repo, settings=settings, watching=watching))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def status_command(repo: RepoKey, *, sync: bool) -> None:
|
|
207
|
+
"""The synchronous CLI boundary for ``review status`` / ``capt-hook status``."""
|
|
208
|
+
asyncio.run(run_status(repo, sync=sync))
|
|
@@ -113,6 +113,22 @@ SELECT c.*,
|
|
|
113
113
|
FROM candidates c
|
|
114
114
|
"""
|
|
115
115
|
|
|
116
|
+
PR_SUMMARY_QUERY = """
|
|
117
|
+
WITH latest AS (
|
|
118
|
+
SELECT v.dedup_key, v.{accepted} AS accepted, v.{summary} AS summary, v.confidence, ROW_NUMBER() OVER (
|
|
119
|
+
PARTITION BY v.dedup_key ORDER BY v.judged_at DESC, v.id DESC
|
|
120
|
+
) AS rn
|
|
121
|
+
FROM {table} v
|
|
122
|
+
WHERE v.role = 'judge' AND v.prompt_version = ?
|
|
123
|
+
)
|
|
124
|
+
SELECT l.summary AS summary
|
|
125
|
+
FROM candidate_observations o
|
|
126
|
+
JOIN latest l ON l.dedup_key = o.dedup_key AND l.rn = 1
|
|
127
|
+
WHERE o.candidate_id = ? AND l.accepted = 1 AND l.confidence >= ?
|
|
128
|
+
ORDER BY l.confidence DESC, o.id DESC
|
|
129
|
+
LIMIT 1
|
|
130
|
+
"""
|
|
131
|
+
|
|
116
132
|
|
|
117
133
|
class InvalidTransition(Exception):
|
|
118
134
|
"""Raised when a candidate status move is outside :data:`TRANSITIONS`."""
|
|
@@ -175,6 +191,47 @@ class ThresholdStatus:
|
|
|
175
191
|
single_observation: bool
|
|
176
192
|
|
|
177
193
|
|
|
194
|
+
def crosses_thresholds(status: ThresholdStatus, *, settings: ReviewSettings) -> bool:
|
|
195
|
+
"""Whether a candidate's judge-accepted evidence clears its kind's PR thresholds.
|
|
196
|
+
|
|
197
|
+
The single eligibility predicate, shared by :meth:`ReviewStore.eligible` and
|
|
198
|
+
the status dashboard so a candidate shown as eligible is exactly one the
|
|
199
|
+
reviewer would act on. Create candidates need ``min_sessions`` distinct
|
|
200
|
+
judge-accepted sessions across ``min_days`` distinct UTC days; fix candidates
|
|
201
|
+
need the ``min_sessions_fix``/``min_days_fix`` pair or one observation that is
|
|
202
|
+
both judge-accepted and heuristically at least ``min_confidence_fix_single``.
|
|
203
|
+
Both require the repo watched and a free slot under ``max_open_prs``.
|
|
204
|
+
"""
|
|
205
|
+
if status.status != CandidateStatus.WATCHING or not status.watching or status.open_prs >= settings.max_open_prs:
|
|
206
|
+
return False
|
|
207
|
+
match status.kind:
|
|
208
|
+
case CandidateKind.CREATE:
|
|
209
|
+
return status.sessions >= settings.min_sessions and status.days >= settings.min_days
|
|
210
|
+
case CandidateKind.FIX:
|
|
211
|
+
return (
|
|
212
|
+
status.sessions >= settings.min_sessions_fix and status.days >= settings.min_days_fix
|
|
213
|
+
) or status.single_observation
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@dataclass(frozen=True, slots=True)
|
|
217
|
+
class CandidateView:
|
|
218
|
+
"""One candidate's full dashboard record: its row, evidence counts, eligibility, and the PR it would open.
|
|
219
|
+
|
|
220
|
+
Attributes:
|
|
221
|
+
row: The :meth:`ReviewStore.candidates` row (status, kind, ``pr_url``,
|
|
222
|
+
``sample_text``, ``observations``, and the fix targets).
|
|
223
|
+
threshold: The judge-accepted evidence counts behind the eligibility call.
|
|
224
|
+
eligible: Whether :func:`crosses_thresholds` accepts ``threshold``.
|
|
225
|
+
summary: The highest-confidence accepted verdict's one-sentence summary —
|
|
226
|
+
what the candidate's PR would do — or ``None`` while still unjudged.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
row: dict[str, object]
|
|
230
|
+
threshold: ThresholdStatus
|
|
231
|
+
eligible: bool
|
|
232
|
+
summary: str | None
|
|
233
|
+
|
|
234
|
+
|
|
178
235
|
class ReviewStore(VerdictStoreMixin, FeedbackStore):
|
|
179
236
|
"""The session reviewer's persistent store over a :class:`FileStateStore`.
|
|
180
237
|
|
|
@@ -411,25 +468,64 @@ class ReviewStore(VerdictStoreMixin, FeedbackStore):
|
|
|
411
468
|
async def eligible(self, candidate_id: int, *, settings: ReviewSettings, prompt_version: int) -> bool:
|
|
412
469
|
"""Returns whether a candidate's judge-accepted evidence crosses its thresholds.
|
|
413
470
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
both judge-accepted and heuristically at least
|
|
418
|
-
``min_confidence_fix_single``. Both kinds require the repo watched and a
|
|
419
|
-
free slot under ``max_open_prs``.
|
|
471
|
+
Delegates to :func:`crosses_thresholds` over the candidate's
|
|
472
|
+
:meth:`threshold_status`, so the dashboard and the reviewer agree on what
|
|
473
|
+
is eligible.
|
|
420
474
|
|
|
421
475
|
Args:
|
|
422
476
|
candidate_id: The candidate to check.
|
|
423
477
|
settings: The thresholds and judge knobs to check under.
|
|
424
478
|
prompt_version: The judge prompt version whose verdicts apply.
|
|
425
479
|
"""
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
480
|
+
return crosses_thresholds(
|
|
481
|
+
await self.threshold_status(candidate_id, settings=settings, prompt_version=prompt_version),
|
|
482
|
+
settings=settings,
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
async def pr_summary(self, candidate_id: int, *, settings: ReviewSettings, prompt_version: int) -> str | None:
|
|
486
|
+
"""Returns the candidate's most-confident accepted verdict summary — what its PR would do.
|
|
487
|
+
|
|
488
|
+
Reads the same judge-accepted observations the thresholds count and
|
|
489
|
+
returns the highest-confidence verdict's one-sentence summary, or ``None``
|
|
490
|
+
while no observation is judged-accepted yet.
|
|
491
|
+
|
|
492
|
+
Args:
|
|
493
|
+
candidate_id: The candidate to describe.
|
|
494
|
+
settings: The judge knobs supplying ``min_judge_confidence``.
|
|
495
|
+
prompt_version: The judge prompt version whose verdicts apply.
|
|
496
|
+
"""
|
|
497
|
+
cur = await self.store.conn.execute(
|
|
498
|
+
PR_SUMMARY_QUERY.format(
|
|
499
|
+
table=self.VERDICT_TABLE, accepted=self.ACCEPTED_COLUMN, summary=self.SUMMARY_COLUMN
|
|
500
|
+
),
|
|
501
|
+
(prompt_version, candidate_id, settings.min_judge_confidence),
|
|
502
|
+
)
|
|
503
|
+
return str(rows[0]["summary"]) if (rows := [dict(row) async for row in cur]) else None
|
|
504
|
+
|
|
505
|
+
async def candidate_view(
|
|
506
|
+
self, row: dict[str, object], *, settings: ReviewSettings, prompt_version: int
|
|
507
|
+
) -> CandidateView:
|
|
508
|
+
"""Assembles one :class:`CandidateView` from a :meth:`candidates` row."""
|
|
509
|
+
candidate_id = int(str(row["id"]))
|
|
510
|
+
threshold = await self.threshold_status(candidate_id, settings=settings, prompt_version=prompt_version)
|
|
511
|
+
return CandidateView(
|
|
512
|
+
row=row,
|
|
513
|
+
threshold=threshold,
|
|
514
|
+
eligible=crosses_thresholds(threshold, settings=settings),
|
|
515
|
+
summary=await self.pr_summary(candidate_id, settings=settings, prompt_version=prompt_version),
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
async def overview(
|
|
519
|
+
self, repo: RepoKey | None = None, *, settings: ReviewSettings, prompt_version: int
|
|
520
|
+
) -> list[CandidateView]:
|
|
521
|
+
"""Returns a :class:`CandidateView` per candidate — the status dashboard's whole read.
|
|
522
|
+
|
|
523
|
+
Args:
|
|
524
|
+
repo: When set, restrict to this repo.
|
|
525
|
+
settings: The thresholds and judge knobs to evaluate under.
|
|
526
|
+
prompt_version: The judge prompt version whose verdicts apply.
|
|
527
|
+
"""
|
|
528
|
+
return [
|
|
529
|
+
await self.candidate_view(row, settings=settings, prompt_version=prompt_version)
|
|
530
|
+
for row in await self.candidates(repo)
|
|
531
|
+
]
|
|
@@ -8,6 +8,7 @@ skipped so the detached child never dies on it.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import asyncio
|
|
11
12
|
import json
|
|
12
13
|
import subprocess
|
|
13
14
|
from collections import Counter
|
|
@@ -75,9 +76,11 @@ async def sync_open_prs(store: ReviewStore, repo: RepoKey, *, settings: ReviewSe
|
|
|
75
76
|
The pass's transition counts.
|
|
76
77
|
"""
|
|
77
78
|
counts: Counter[str] = Counter()
|
|
78
|
-
|
|
79
|
+
rows = await store.candidates(repo, status=CandidateStatus.PR_OPEN)
|
|
80
|
+
states = await asyncio.gather(*(asyncio.to_thread(gh_pr_state, str(row["pr_url"])) for row in rows))
|
|
81
|
+
for row, state in zip(rows, states, strict=True):
|
|
79
82
|
candidate_id, url = int(str(row["id"])), str(row["pr_url"])
|
|
80
|
-
match
|
|
83
|
+
match state:
|
|
81
84
|
case "MERGED":
|
|
82
85
|
await store.transition(candidate_id, CandidateStatus.ACCEPTED)
|
|
83
86
|
counts["accepted"] += 1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "capt-hook"
|
|
3
|
-
version = "3.
|
|
3
|
+
version = "3.4.0"
|
|
4
4
|
description = "Declarative hook framework for Claude Code"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "PolyForm-Noncommercial-1.0.0"
|
|
@@ -28,6 +28,7 @@ dependencies = [
|
|
|
28
28
|
"funcy>=2.0",
|
|
29
29
|
"spacy>=3.7",
|
|
30
30
|
"click>=8",
|
|
31
|
+
"rich>=13",
|
|
31
32
|
"orjsonl>=1.0",
|
|
32
33
|
"wn>=1.1.0",
|
|
33
34
|
"lazy-object-proxy>=1.12.0",
|
capt_hook-3.3.2/PKG-INFO
DELETED
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: capt-hook
|
|
3
|
-
Version: 3.3.2
|
|
4
|
-
Summary: Declarative hook framework for Claude Code
|
|
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>=3.2,<4
|
|
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: funcy>=2.0
|
|
25
|
-
Requires-Dist: spacy>=3.7
|
|
26
|
-
Requires-Dist: click>=8
|
|
27
|
-
Requires-Dist: orjsonl>=1.0
|
|
28
|
-
Requires-Dist: wn>=1.1.0
|
|
29
|
-
Requires-Dist: lazy-object-proxy>=1.12.0
|
|
30
|
-
Requires-Dist: filelock>=3
|
|
31
|
-
Requires-Dist: loguru>=0.7.3
|
|
32
|
-
Requires-Dist: spawnllm>=0.1.3
|
|
33
|
-
Requires-Dist: pytest>=8.0 ; extra == 'dev'
|
|
34
|
-
Requires-Dist: pytest-asyncio>=0.24 ; extra == 'dev'
|
|
35
|
-
Requires-Dist: pyright>=1.1 ; extra == 'dev'
|
|
36
|
-
Requires-Dist: pyyaml>=6 ; extra == 'dev'
|
|
37
|
-
Requires-Dist: ruff>=0.8 ; extra == 'dev'
|
|
38
|
-
Requires-Python: >=3.13
|
|
39
|
-
Project-URL: Homepage, https://github.com/yasyf/captain-hook
|
|
40
|
-
Project-URL: Documentation, https://yasyf.github.io/captain-hook/
|
|
41
|
-
Project-URL: Repository, https://github.com/yasyf/captain-hook
|
|
42
|
-
Project-URL: Issues, https://github.com/yasyf/captain-hook/issues
|
|
43
|
-
Project-URL: Changelog, https://github.com/yasyf/captain-hook/blob/main/CHANGELOG.md
|
|
44
|
-
Provides-Extra: dev
|
|
45
|
-
Description-Content-Type: text/markdown
|
|
46
|
-
|
|
47
|
-
# captain-hook
|
|
48
|
-
|
|
49
|
-

|
|
50
|
-
|
|
51
|
-
[](https://pypi.org/project/capt-hook/)
|
|
52
|
-
[](https://pypi.org/project/capt-hook/)
|
|
53
|
-
[](https://yasyf.github.io/captain-hook/)
|
|
54
|
-
[](https://github.com/yasyf/captain-hook/blob/main/LICENSE)
|
|
55
|
-
|
|
56
|
-
Declarative hook framework for Claude Code. Write hooks as data, test them inline, and ship them to CI in the same shape they run in production.
|
|
57
|
-
|
|
58
|
-
## Quickstart
|
|
59
|
-
|
|
60
|
-
No install step — everything runs through [uvx](https://docs.astral.sh/uv/). Pick a front door:
|
|
61
|
-
|
|
62
|
-
**From your terminal:**
|
|
63
|
-
|
|
64
|
-
```bash
|
|
65
|
-
uvx capt-hook init
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
**From inside Claude Code** — install the plugin, then ask Claude to set it up:
|
|
69
|
-
|
|
70
|
-
```
|
|
71
|
-
/plugin marketplace add yasyf/captain-hook
|
|
72
|
-
/plugin install captain-hook@captain-hook
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
> set up captain hook
|
|
76
|
-
|
|
77
|
-
Either path lands in the same place: `.claude/hooks/` scaffolded, Claude Code's settings wired, the bundled skills installed, and the [session reviewer](#session-reviewer) watching this repo. `uvx` fetches captain-hook into a throwaway environment, so it never enters your `pyproject.toml` — and every command below works the same way once you prefix it with `uvx`.
|
|
78
|
-
|
|
79
|
-
## Your first hook
|
|
80
|
-
|
|
81
|
-
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.
|
|
82
|
-
|
|
83
|
-
```python
|
|
84
|
-
# .claude/hooks/visual_review.py
|
|
85
|
-
from captain_hook import gate, TouchedFile, UsedSkill
|
|
86
|
-
|
|
87
|
-
# A Stop gate: before the agent finishes, block if it edited UI files without doing a visual review.
|
|
88
|
-
gate(
|
|
89
|
-
# the one-line reason shown to the agent when the gate fires
|
|
90
|
-
"You edited UI files. Open them with agent-browser and verify they render before finishing.",
|
|
91
|
-
# fires only if UI files changed
|
|
92
|
-
only_if=[TouchedFile("**/src/routes/**", "**/src/components/**")],
|
|
93
|
-
# already reviewed -> don't block
|
|
94
|
-
skip_if=[UsedSkill("agent-browser")],
|
|
95
|
-
)
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
Conditions match tools, files, commands, and even which skills the agent used.
|
|
99
|
-
|
|
100
|
-
## Test your hooks
|
|
101
|
-
|
|
102
|
-
Every deterministic hook carries inline tests, so a broken hook fails like broken code. Run them from your project root, where `--hooks` defaults to `.claude/hooks`.
|
|
103
|
-
|
|
104
|
-
```python
|
|
105
|
-
# .claude/hooks/safety.py
|
|
106
|
-
from captain_hook import Allow, Block, Input, block_command
|
|
107
|
-
|
|
108
|
-
block_command(
|
|
109
|
-
["git", "stash"],
|
|
110
|
-
reason="Use the team's VCS workflow for shelving changes",
|
|
111
|
-
hint="Commit a WIP change instead of stashing",
|
|
112
|
-
tests={
|
|
113
|
-
Input(command="git stash"): Block(),
|
|
114
|
-
Input(command="git status"): Allow(),
|
|
115
|
-
},
|
|
116
|
-
)
|
|
117
|
-
```
|
|
118
|
-
|
|
119
|
-
```bash
|
|
120
|
-
uvx capt-hook test
|
|
121
|
-
```
|
|
122
|
-
|
|
123
|
-
`init` already wired Claude Code's settings. Each event runs `uvx capt-hook run <Event>`, with the event JSON arriving on stdin and the verdict written to stdout. Re-run `uvx capt-hook register-hooks` only after you add hooks on a new event; it writes `.claude/settings.local.json` for you.
|
|
124
|
-
|
|
125
|
-
## Session reviewer
|
|
126
|
-
|
|
127
|
-
`init` also turns on the **session reviewer**. When a Claude Code session ends, it mines the transcript for the durable corrections you gave and the hooks that misfired, judges each one, and — once a pattern clears its thresholds — opens a pull request that adds a new hook or fixes the one that misfired. You review the PR like any other.
|
|
128
|
-
|
|
129
|
-
It's on by default after `init`. Turn it off for a repo with `uvx capt-hook review disable`, or skip it at setup with `uvx capt-hook init --no-review`. The [session reviewer guide](https://yasyf.github.io/captain-hook/docs/guide/session-reviewer.html) covers prerequisites (an authenticated `claude` and `gh`) and the `HOOKS_REVIEW_*` tuning knobs.
|
|
130
|
-
|
|
131
|
-
## Agent Skills
|
|
132
|
-
|
|
133
|
-
captain-hook ships two [Agent Skills](https://yasyf.github.io/captain-hook/docs/getting-started/skills.html) so you don't have to write hooks by hand. `bootstrapping-hooks` surveys your repo's docs, CI, and git history and proposes gates and nudges; `translating-styleguides` turns a STYLEGUIDE.md into enforced rules. Both land in `.claude/skills/` via `init` and ship as the plugin in the [Quickstart](#quickstart) — ask Claude to "set up captain hook" and `bootstrapping-hooks` takes it from there.
|
|
134
|
-
|
|
135
|
-
## What this solves
|
|
136
|
-
|
|
137
|
-
captain-hook covers these jobs:
|
|
138
|
-
|
|
139
|
-
- Block dangerous tool calls before they execute on `PreToolUse`, like force-push, package-manager footguns, and raw `rm -rf`.
|
|
140
|
-
- Drive the agent with feedback that fires on the patterns it actually emits, such as repeated failures, weakened tests, and missed conventions.
|
|
141
|
-
- 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.
|
|
142
|
-
- 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.
|
|
143
|
-
|
|
144
|
-
## Docs
|
|
145
|
-
|
|
146
|
-
[Read the docs](https://yasyf.github.io/captain-hook/) for the full guide to conditions, primitives, LLM hooks, workflows, state, and real-world patterns.
|
|
147
|
-
|
|
148
|
-
For working on captain-hook itself, see the [development guide](https://yasyf.github.io/captain-hook/docs/development/).
|
|
149
|
-
|
|
150
|
-
## License
|
|
151
|
-
|
|
152
|
-
Licensed under [PolyForm Noncommercial 1.0.0](LICENSE), free for noncommercial use.
|
capt_hook-3.3.2/README.md
DELETED
|
@@ -1,106 +0,0 @@
|
|
|
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
|
-
Declarative hook framework for Claude Code. Write hooks as data, test them inline, and ship them to CI in the same shape they run in production.
|
|
11
|
-
|
|
12
|
-
## Quickstart
|
|
13
|
-
|
|
14
|
-
No install step — everything runs through [uvx](https://docs.astral.sh/uv/). Pick a front door:
|
|
15
|
-
|
|
16
|
-
**From your terminal:**
|
|
17
|
-
|
|
18
|
-
```bash
|
|
19
|
-
uvx capt-hook init
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
**From inside Claude Code** — install the plugin, then ask Claude to set it up:
|
|
23
|
-
|
|
24
|
-
```
|
|
25
|
-
/plugin marketplace add yasyf/captain-hook
|
|
26
|
-
/plugin install captain-hook@captain-hook
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
> set up captain hook
|
|
30
|
-
|
|
31
|
-
Either path lands in the same place: `.claude/hooks/` scaffolded, Claude Code's settings wired, the bundled skills installed, and the [session reviewer](#session-reviewer) watching this repo. `uvx` fetches captain-hook into a throwaway environment, so it never enters your `pyproject.toml` — and every command below works the same way once you prefix it with `uvx`.
|
|
32
|
-
|
|
33
|
-
## Your first hook
|
|
34
|
-
|
|
35
|
-
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.
|
|
36
|
-
|
|
37
|
-
```python
|
|
38
|
-
# .claude/hooks/visual_review.py
|
|
39
|
-
from captain_hook import gate, TouchedFile, UsedSkill
|
|
40
|
-
|
|
41
|
-
# A Stop gate: before the agent finishes, block if it edited UI files without doing a visual review.
|
|
42
|
-
gate(
|
|
43
|
-
# the one-line reason shown to the agent when the gate fires
|
|
44
|
-
"You edited UI files. Open them with agent-browser and verify they render before finishing.",
|
|
45
|
-
# fires only if UI files changed
|
|
46
|
-
only_if=[TouchedFile("**/src/routes/**", "**/src/components/**")],
|
|
47
|
-
# already reviewed -> don't block
|
|
48
|
-
skip_if=[UsedSkill("agent-browser")],
|
|
49
|
-
)
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
Conditions match tools, files, commands, and even which skills the agent used.
|
|
53
|
-
|
|
54
|
-
## Test your hooks
|
|
55
|
-
|
|
56
|
-
Every deterministic hook carries inline tests, so a broken hook fails like broken code. Run them from your project root, where `--hooks` defaults to `.claude/hooks`.
|
|
57
|
-
|
|
58
|
-
```python
|
|
59
|
-
# .claude/hooks/safety.py
|
|
60
|
-
from captain_hook import Allow, Block, Input, block_command
|
|
61
|
-
|
|
62
|
-
block_command(
|
|
63
|
-
["git", "stash"],
|
|
64
|
-
reason="Use the team's VCS workflow for shelving changes",
|
|
65
|
-
hint="Commit a WIP change instead of stashing",
|
|
66
|
-
tests={
|
|
67
|
-
Input(command="git stash"): Block(),
|
|
68
|
-
Input(command="git status"): Allow(),
|
|
69
|
-
},
|
|
70
|
-
)
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
```bash
|
|
74
|
-
uvx capt-hook test
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
`init` already wired Claude Code's settings. Each event runs `uvx capt-hook run <Event>`, with the event JSON arriving on stdin and the verdict written to stdout. Re-run `uvx capt-hook register-hooks` only after you add hooks on a new event; it writes `.claude/settings.local.json` for you.
|
|
78
|
-
|
|
79
|
-
## Session reviewer
|
|
80
|
-
|
|
81
|
-
`init` also turns on the **session reviewer**. When a Claude Code session ends, it mines the transcript for the durable corrections you gave and the hooks that misfired, judges each one, and — once a pattern clears its thresholds — opens a pull request that adds a new hook or fixes the one that misfired. You review the PR like any other.
|
|
82
|
-
|
|
83
|
-
It's on by default after `init`. Turn it off for a repo with `uvx capt-hook review disable`, or skip it at setup with `uvx capt-hook init --no-review`. The [session reviewer guide](https://yasyf.github.io/captain-hook/docs/guide/session-reviewer.html) covers prerequisites (an authenticated `claude` and `gh`) and the `HOOKS_REVIEW_*` tuning knobs.
|
|
84
|
-
|
|
85
|
-
## Agent Skills
|
|
86
|
-
|
|
87
|
-
captain-hook ships two [Agent Skills](https://yasyf.github.io/captain-hook/docs/getting-started/skills.html) so you don't have to write hooks by hand. `bootstrapping-hooks` surveys your repo's docs, CI, and git history and proposes gates and nudges; `translating-styleguides` turns a STYLEGUIDE.md into enforced rules. Both land in `.claude/skills/` via `init` and ship as the plugin in the [Quickstart](#quickstart) — ask Claude to "set up captain hook" and `bootstrapping-hooks` takes it from there.
|
|
88
|
-
|
|
89
|
-
## What this solves
|
|
90
|
-
|
|
91
|
-
captain-hook covers these jobs:
|
|
92
|
-
|
|
93
|
-
- Block dangerous tool calls before they execute on `PreToolUse`, like force-push, package-manager footguns, and raw `rm -rf`.
|
|
94
|
-
- Drive the agent with feedback that fires on the patterns it actually emits, such as repeated failures, weakened tests, and missed conventions.
|
|
95
|
-
- 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.
|
|
96
|
-
- 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.
|
|
97
|
-
|
|
98
|
-
## Docs
|
|
99
|
-
|
|
100
|
-
[Read the docs](https://yasyf.github.io/captain-hook/) for the full guide to conditions, primitives, LLM hooks, workflows, state, and real-world patterns.
|
|
101
|
-
|
|
102
|
-
For working on captain-hook itself, see the [development guide](https://yasyf.github.io/captain-hook/docs/development/).
|
|
103
|
-
|
|
104
|
-
## License
|
|
105
|
-
|
|
106
|
-
Licensed under [PolyForm Noncommercial 1.0.0](LICENSE), free for noncommercial use.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md
RENAMED
|
File without changes
|
|
File without changes
|
{capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md
RENAMED
|
File without changes
|
{capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md
RENAMED
|
File without changes
|
{capt_hook-3.3.2 → capt_hook-3.4.0}/captain_hook/skills/scanning-sessions/references/review-cli.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|