capt-hook 3.1.0__tar.gz → 3.3.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.1.0 → capt_hook-3.3.0}/PKG-INFO +21 -15
- {capt_hook-3.1.0 → capt_hook-3.3.0}/README.md +20 -14
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/cli.py +127 -29
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/loader.py +18 -0
- capt_hook-3.3.0/captain_hook/packs/__init__.py +1 -0
- capt_hook-3.3.0/captain_hook/packs/general/capt-hook.toml +4 -0
- capt_hook-3.3.0/captain_hook/packs/general/commands.py +71 -0
- capt_hook-3.3.0/captain_hook/packs/general/docs.py +23 -0
- capt_hook-3.3.0/captain_hook/packs/general/plans.py +69 -0
- capt_hook-3.3.0/captain_hook/packs/general/prompts.py +33 -0
- capt_hook-3.3.0/captain_hook/packs/general/review.py +44 -0
- capt_hook-3.3.0/captain_hook/packs/general/stewardship.py +269 -0
- capt_hook-3.3.0/captain_hook/packs/general/tasks.py +147 -0
- capt_hook-3.3.0/captain_hook/packs/manager.py +256 -0
- capt_hook-3.3.0/captain_hook/packs/python/capt-hook.toml +4 -0
- capt_hook-3.3.0/captain_hook/packs/python/style.py +147 -0
- capt_hook-3.3.0/captain_hook/packs/python/testing.py +87 -0
- capt_hook-3.3.0/captain_hook/packs/python/toolchain.py +32 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/review/cli.py +16 -9
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/review/settings.py +1 -1
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +28 -19
- {capt_hook-3.1.0 → capt_hook-3.3.0}/pyproject.toml +1 -1
- {capt_hook-3.1.0 → capt_hook-3.3.0}/LICENSE +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/.claude-plugin/plugin.json +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/__init__.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/__main__.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/app.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/classifiers/__init__.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/classifiers/conductor.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/classifiers/droid.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/classifiers/native.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/command.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/conditions.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/context.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/decisions.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/dispatch.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/events.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/file.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/llm/__init__.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/log.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/primitives/__init__.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/primitives/commands.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/primitives/lint.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/primitives/llm.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/primitives/nudge.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/primitives/workflow.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/prompt.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/py.typed +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/review/__init__.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/review/fix.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/review/formats.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/review/judge.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/review/pipeline.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/review/repo.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/review/scan.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/review/store.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/review/sync.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/session.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/settings.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/signals/__init__.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/signals/nlp.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/skills/authoring-hooks/SKILL.md +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/skills/scanning-sessions/SKILL.md +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/state.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/style/__init__.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/style/matchers.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/style/scope.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/style/types.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/tasks.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/testing/__init__.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/testing/helpers.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/testing/session_cache.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/testing/types.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/tests/__init__.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/tests/helpers.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/types.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/util/__init__.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/util/model_cache.py +0 -0
- {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: capt-hook
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.3.0
|
|
4
4
|
Summary: Declarative hook framework for Claude Code
|
|
5
5
|
Keywords: claude,claude-code,hooks,llm,agents,guardrails,cli
|
|
6
6
|
Author: Yasyf Mohamedali
|
|
@@ -55,23 +55,28 @@ Description-Content-Type: text/markdown
|
|
|
55
55
|
|
|
56
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
57
|
|
|
58
|
-
##
|
|
58
|
+
## Quickstart
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
No install step — everything runs through [uvx](https://docs.astral.sh/uv/). Pick a front door:
|
|
61
|
+
|
|
62
|
+
**From your terminal:**
|
|
61
63
|
|
|
62
64
|
```bash
|
|
63
65
|
uvx capt-hook init
|
|
64
66
|
```
|
|
65
67
|
|
|
66
|
-
|
|
68
|
+
**From inside Claude Code** — install the plugin, then ask Claude to set it up:
|
|
67
69
|
|
|
68
|
-
|
|
70
|
+
```
|
|
71
|
+
/plugin marketplace add yasyf/captain-hook
|
|
72
|
+
/plugin install captain-hook@captain-hook
|
|
73
|
+
```
|
|
69
74
|
|
|
70
|
-
|
|
75
|
+
> set up captain hook
|
|
71
76
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
75
80
|
|
|
76
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.
|
|
77
82
|
|
|
@@ -117,14 +122,15 @@ uvx capt-hook test
|
|
|
117
122
|
|
|
118
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.
|
|
119
124
|
|
|
120
|
-
##
|
|
125
|
+
## Session reviewer
|
|
121
126
|
|
|
122
|
-
|
|
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.
|
|
123
128
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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.
|
|
128
134
|
|
|
129
135
|
## What this solves
|
|
130
136
|
|
|
@@ -9,23 +9,28 @@
|
|
|
9
9
|
|
|
10
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
11
|
|
|
12
|
-
##
|
|
12
|
+
## Quickstart
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
No install step — everything runs through [uvx](https://docs.astral.sh/uv/). Pick a front door:
|
|
15
|
+
|
|
16
|
+
**From your terminal:**
|
|
15
17
|
|
|
16
18
|
```bash
|
|
17
19
|
uvx capt-hook init
|
|
18
20
|
```
|
|
19
21
|
|
|
20
|
-
|
|
22
|
+
**From inside Claude Code** — install the plugin, then ask Claude to set it up:
|
|
21
23
|
|
|
22
|
-
|
|
24
|
+
```
|
|
25
|
+
/plugin marketplace add yasyf/captain-hook
|
|
26
|
+
/plugin install captain-hook@captain-hook
|
|
27
|
+
```
|
|
23
28
|
|
|
24
|
-
|
|
29
|
+
> set up captain hook
|
|
25
30
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
29
34
|
|
|
30
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.
|
|
31
36
|
|
|
@@ -71,14 +76,15 @@ uvx capt-hook test
|
|
|
71
76
|
|
|
72
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.
|
|
73
78
|
|
|
74
|
-
##
|
|
79
|
+
## Session reviewer
|
|
75
80
|
|
|
76
|
-
|
|
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.
|
|
77
82
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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.
|
|
82
88
|
|
|
83
89
|
## What this solves
|
|
84
90
|
|
|
@@ -17,8 +17,9 @@ from cc_transcript.ids import SessionId
|
|
|
17
17
|
from captain_hook.app import _state, load_gitignore, reset
|
|
18
18
|
from captain_hook.context import HookContext, load_transcript
|
|
19
19
|
from captain_hook.dispatch import dispatch
|
|
20
|
-
from captain_hook.loader import discover_hooks
|
|
20
|
+
from captain_hook.loader import CONF_MODULE, discover_hooks, discover_pack
|
|
21
21
|
from captain_hook.log import setup_logging
|
|
22
|
+
from captain_hook.packs import manager
|
|
22
23
|
from captain_hook.review.cli import review
|
|
23
24
|
from captain_hook.session import SessionStore, ensure_session
|
|
24
25
|
from captain_hook.types import Event
|
|
@@ -38,6 +39,14 @@ class CliState:
|
|
|
38
39
|
reset()
|
|
39
40
|
load_gitignore(self.root)
|
|
40
41
|
discover_hooks(self.hooks)
|
|
42
|
+
resolved, missing = manager.resolve_enabled_packs(self.root)
|
|
43
|
+
for pack_ in resolved:
|
|
44
|
+
discover_pack(pack_.entry.name, pack_.path)
|
|
45
|
+
if missing:
|
|
46
|
+
print(
|
|
47
|
+
f"capt-hook: packs not cached: {', '.join(missing)} — run `capt-hook pack update`",
|
|
48
|
+
file=sys.stderr,
|
|
49
|
+
)
|
|
41
50
|
|
|
42
51
|
|
|
43
52
|
def example_hook_source() -> str:
|
|
@@ -187,17 +196,25 @@ def print_hook_summary(label: str, summary: dict[str, str]) -> None:
|
|
|
187
196
|
by_status: defaultdict[str, list[str]] = defaultdict(list)
|
|
188
197
|
for event, status in summary.items():
|
|
189
198
|
by_status[status].append(event)
|
|
190
|
-
|
|
199
|
+
click.echo(f"{label}:")
|
|
191
200
|
if not summary:
|
|
192
|
-
|
|
201
|
+
click.echo(" no hook entries")
|
|
193
202
|
for event in by_status["added"]:
|
|
194
|
-
|
|
203
|
+
click.echo(f" + added {event}")
|
|
195
204
|
for event in by_status["updated"]:
|
|
196
|
-
|
|
205
|
+
click.echo(f" ~ updated {event}")
|
|
197
206
|
for event in by_status["removed"]:
|
|
198
|
-
|
|
207
|
+
click.echo(f" - removed {event}")
|
|
199
208
|
if unchanged := by_status["unchanged"]:
|
|
200
|
-
|
|
209
|
+
click.echo(f" unchanged: {', '.join(unchanged)} (already present)")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def regenerate_settings(state: CliState) -> None:
|
|
213
|
+
state.discover()
|
|
214
|
+
settings_path = state.root / ".claude" / "settings.local.json"
|
|
215
|
+
merged, summary = merge_settings(".claude/hooks", settings_path)
|
|
216
|
+
write_settings(settings_path, merged)
|
|
217
|
+
print_hook_summary(str(settings_path.relative_to(state.root)), summary)
|
|
201
218
|
|
|
202
219
|
|
|
203
220
|
def settings_drift(root: Path) -> set[str]:
|
|
@@ -286,40 +303,51 @@ def run_event(
|
|
|
286
303
|
print(json.dumps(output))
|
|
287
304
|
|
|
288
305
|
|
|
289
|
-
def init_project(root: Path) -> None:
|
|
306
|
+
def init_project(root: Path, *, review: bool = True) -> None:
|
|
307
|
+
from captain_hook.review.cli import watch_repo
|
|
308
|
+
from captain_hook.review.repo import repo_key
|
|
309
|
+
|
|
290
310
|
hooks_dir = root / ".claude" / "hooks"
|
|
291
311
|
hooks_dir.mkdir(parents=True, exist_ok=True)
|
|
292
312
|
|
|
293
313
|
example = hooks_dir / "example.py"
|
|
294
|
-
|
|
295
|
-
if example_created:
|
|
314
|
+
if not example.exists():
|
|
296
315
|
example.write_text(example_hook_source())
|
|
297
316
|
|
|
298
317
|
settings_path = root / ".claude" / "settings.local.json"
|
|
299
|
-
|
|
300
|
-
discover_hooks(str(hooks_dir))
|
|
318
|
+
CliState(root=root, hooks=str(hooks_dir)).discover()
|
|
301
319
|
merged, summary = merge_settings(".claude/hooks", settings_path)
|
|
302
320
|
write_settings(settings_path, merged)
|
|
303
321
|
|
|
304
322
|
skills_summary = install_skills(root)
|
|
305
323
|
|
|
306
|
-
|
|
307
|
-
|
|
324
|
+
click.echo(f"Scaffolded {example.relative_to(root)} + {settings_path.relative_to(root)}.")
|
|
325
|
+
click.echo()
|
|
308
326
|
print_hook_summary(str(settings_path.relative_to(root)), summary)
|
|
309
|
-
|
|
310
|
-
|
|
327
|
+
click.echo()
|
|
328
|
+
click.echo(".claude/skills/:")
|
|
311
329
|
for name in (n for n, status in skills_summary.items() if status == "installed"):
|
|
312
|
-
|
|
330
|
+
click.echo(f" + installed {name}")
|
|
313
331
|
if skipped := [n for n, status in skills_summary.items() if status == "skipped"]:
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
332
|
+
click.echo(f" unchanged: {', '.join(skipped)} (already present; capt-hook skills install --force to refresh)")
|
|
333
|
+
click.echo()
|
|
334
|
+
match (review, repo_key(root)):
|
|
335
|
+
case (False, _):
|
|
336
|
+
click.echo("Session reviewer: skipped (--no-review) — `uvx capt-hook review enable` to turn it on later.")
|
|
337
|
+
case (True, None):
|
|
338
|
+
click.echo("Session reviewer: needs a git repo with a remote — `uvx capt-hook review enable` once it has one.")
|
|
339
|
+
case (True, repo):
|
|
340
|
+
watch_repo(repo)
|
|
341
|
+
click.echo(f"Session reviewer: watching {repo} — mines your ended sessions and opens hook PRs automatically.")
|
|
342
|
+
click.echo(" Stop anytime with `uvx capt-hook review disable`.")
|
|
343
|
+
click.echo()
|
|
344
|
+
click.echo("Next:")
|
|
345
|
+
click.echo(" 1. Read the quickstart: https://yasyf.github.io/captain-hook/")
|
|
346
|
+
click.echo(" 2. Edit example.py or add new files under .claude/hooks/")
|
|
347
|
+
click.echo(" 3. uvx capt-hook test # verify inline tests")
|
|
348
|
+
click.echo(" 4. uvx capt-hook register-hooks # re-register after adding events")
|
|
349
|
+
click.echo(' 5. Ask Claude "set up captain hook" # mine guardrails from this repo (the bootstrapping-hooks skill)')
|
|
350
|
+
click.echo()
|
|
323
351
|
maybe_launch_bootstrap(root)
|
|
324
352
|
|
|
325
353
|
|
|
@@ -477,10 +505,11 @@ def test(state: CliState, json_output: bool) -> None:
|
|
|
477
505
|
|
|
478
506
|
|
|
479
507
|
@cli.command()
|
|
508
|
+
@click.option("--no-review", is_flag=True, default=False, help="Skip enabling the SessionEnd session reviewer for this repo")
|
|
480
509
|
@click.pass_obj
|
|
481
|
-
def init(state: CliState) -> None:
|
|
482
|
-
"""Scaffold the hooks directory, install bundled skills, and
|
|
483
|
-
init_project(state.root)
|
|
510
|
+
def init(state: CliState, no_review: bool) -> None:
|
|
511
|
+
"""Scaffold the hooks directory, install bundled skills, wire settings, and enable the session reviewer."""
|
|
512
|
+
init_project(state.root, review=not no_review)
|
|
484
513
|
|
|
485
514
|
|
|
486
515
|
@cli.command()
|
|
@@ -505,6 +534,75 @@ def skills_install(state: CliState, force: bool) -> None:
|
|
|
505
534
|
click.echo(f" {status} {name}")
|
|
506
535
|
|
|
507
536
|
|
|
537
|
+
@cli.group()
|
|
538
|
+
def pack() -> None:
|
|
539
|
+
"""Manage capt-hook packs — named collections of hooks (builtin or from GitHub)."""
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
@pack.command(name="add")
|
|
543
|
+
@click.argument("target")
|
|
544
|
+
@click.pass_obj
|
|
545
|
+
def pack_add(state: CliState, target: str) -> None:
|
|
546
|
+
"""Enable a builtin pack by name, or an external pack as github:owner/repo[@ref]."""
|
|
547
|
+
try:
|
|
548
|
+
entry = (
|
|
549
|
+
manager.BuiltinPack(name=target)
|
|
550
|
+
if target in manager.builtin_packs()
|
|
551
|
+
else manager.fetch_pack(manager.PackSource.parse(target)).entry
|
|
552
|
+
)
|
|
553
|
+
except manager.PackError as e:
|
|
554
|
+
raise click.ClickException(str(e)) from e
|
|
555
|
+
manager.upsert_entry(manager.packs_toml_path(state.root), entry)
|
|
556
|
+
click.echo(f" added {entry.name}")
|
|
557
|
+
regenerate_settings(state)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
@pack.command(name="list")
|
|
561
|
+
@click.pass_obj
|
|
562
|
+
def pack_list(state: CliState) -> None:
|
|
563
|
+
"""List the packs enabled in .claude/hooks/packs.toml."""
|
|
564
|
+
resolved, missing = manager.resolve_enabled_packs(state.root)
|
|
565
|
+
for r in resolved:
|
|
566
|
+
match r.entry:
|
|
567
|
+
case manager.BuiltinPack():
|
|
568
|
+
kind, ref = "builtin", "-"
|
|
569
|
+
case manager.ExternalPack(source=source, commit=commit):
|
|
570
|
+
kind, ref = "github", f"{source.ref or 'HEAD'}@{commit[:7]}"
|
|
571
|
+
count = sum(1 for p in r.path.glob("*.py") if not p.stem.startswith("_") and p.stem != CONF_MODULE)
|
|
572
|
+
click.echo(f" {r.entry.name:24} {kind:8} {ref:20} v{r.manifest.version:8} {count} hooks")
|
|
573
|
+
for name in missing:
|
|
574
|
+
click.echo(f" {name:24} github (not cached — run `capt-hook pack update`)")
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
@pack.command(name="remove")
|
|
578
|
+
@click.argument("name")
|
|
579
|
+
@click.pass_obj
|
|
580
|
+
def pack_remove(state: CliState, name: str) -> None:
|
|
581
|
+
"""Disable a pack (leaves its content-addressed cache intact)."""
|
|
582
|
+
try:
|
|
583
|
+
manager.delete_entry(manager.packs_toml_path(state.root), name)
|
|
584
|
+
except manager.PackError as e:
|
|
585
|
+
raise click.ClickException(str(e)) from e
|
|
586
|
+
click.echo(f" removed {name}")
|
|
587
|
+
regenerate_settings(state)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
@pack.command(name="update")
|
|
591
|
+
@click.argument("name", required=False)
|
|
592
|
+
@click.pass_obj
|
|
593
|
+
def pack_update(state: CliState, name: str | None) -> None:
|
|
594
|
+
"""Re-resolve external packs' refs to fresh commits and re-fetch."""
|
|
595
|
+
path = manager.packs_toml_path(state.root)
|
|
596
|
+
for entry in manager.read_entries(path):
|
|
597
|
+
match entry:
|
|
598
|
+
case manager.ExternalPack(name=n, source=source) if name in (None, n):
|
|
599
|
+
manager.upsert_entry(path, fresh := manager.fetch_pack(source).entry)
|
|
600
|
+
click.echo(f" updated {n} -> {fresh.commit[:7]}")
|
|
601
|
+
case manager.BuiltinPack(name=n) if name == n:
|
|
602
|
+
click.echo(f" {n} is builtin; it tracks the installed capt-hook version")
|
|
603
|
+
regenerate_settings(state)
|
|
604
|
+
|
|
605
|
+
|
|
508
606
|
cli.add_command(review)
|
|
509
607
|
|
|
510
608
|
main = cli
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import importlib
|
|
6
|
+
import importlib.machinery
|
|
6
7
|
import importlib.util
|
|
7
8
|
import pkgutil
|
|
9
|
+
import re
|
|
8
10
|
import sys
|
|
9
11
|
from pathlib import Path
|
|
10
12
|
from types import ModuleType
|
|
@@ -14,6 +16,7 @@ from pydantic_settings import BaseSettings
|
|
|
14
16
|
from captain_hook.app import State, _state
|
|
15
17
|
|
|
16
18
|
CONF_MODULE = "conf"
|
|
19
|
+
PACK_PACKAGE_PREFIX = "captain_hook._packs"
|
|
17
20
|
|
|
18
21
|
|
|
19
22
|
def build_hook_settings(module: ModuleType) -> BaseSettings | ModuleType:
|
|
@@ -61,3 +64,18 @@ def discover_hooks(hooks_dir: str | Path, state: State | None = None) -> None:
|
|
|
61
64
|
|
|
62
65
|
for fqn in sorted(all_modules - {f"{pkg}.{CONF_MODULE}"}):
|
|
63
66
|
import_or_reload(fqn, fresh_this_pass)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def import_pack_module(fqn: str, path: Path) -> ModuleType:
|
|
70
|
+
loader = importlib.machinery.SourceFileLoader(fqn, str(path))
|
|
71
|
+
module = importlib.util.module_from_spec(importlib.machinery.ModuleSpec(fqn, loader, origin=str(path)))
|
|
72
|
+
sys.modules[fqn] = module
|
|
73
|
+
loader.exec_module(module)
|
|
74
|
+
return module
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def discover_pack(name: str, pack_dir: Path) -> None:
|
|
78
|
+
pkg = f"{PACK_PACKAGE_PREFIX}.{re.sub(r'\W', '_', name)}"
|
|
79
|
+
for path in sorted(pack_dir.glob("*.py")):
|
|
80
|
+
if not (path.stem.startswith("_") or path.stem == CONF_MODULE):
|
|
81
|
+
import_pack_module(f"{pkg}.{path.stem}", path)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from captain_hook import (
|
|
4
|
+
Allow,
|
|
5
|
+
BaseHookEvent,
|
|
6
|
+
Block,
|
|
7
|
+
CustomCondition,
|
|
8
|
+
Event,
|
|
9
|
+
Input,
|
|
10
|
+
Tool,
|
|
11
|
+
UsedSkill,
|
|
12
|
+
block_command,
|
|
13
|
+
hook,
|
|
14
|
+
nudge,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
block_command(
|
|
18
|
+
["git", "stash"],
|
|
19
|
+
reason="git stash is not allowed",
|
|
20
|
+
hint="Commit your changes to a branch instead",
|
|
21
|
+
tests={
|
|
22
|
+
Input(command="git stash"): Block(),
|
|
23
|
+
Input(command="git stash pop"): Block(),
|
|
24
|
+
Input(command="git status"): Allow(),
|
|
25
|
+
},
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class UnpipedGrep(CustomCondition):
|
|
30
|
+
"""True when a `grep` command does not consume piped input.
|
|
31
|
+
|
|
32
|
+
Allows the stream-filter idiom (`… | grep`) while still blocking grep used
|
|
33
|
+
for file searching, whether standalone, heading a pipe, or in a `&&`/`;` chain.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def check(self, evt: BaseHookEvent) -> bool:
|
|
37
|
+
if not (cl := evt.command_line):
|
|
38
|
+
return False
|
|
39
|
+
return any(
|
|
40
|
+
cmd.matches(r"^grep\b") and (i == 0 or cl.parts[i - 1][1] != "|") for i, (cmd, _) in enumerate(cl.parts)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
hook(
|
|
45
|
+
Event.PreToolUse,
|
|
46
|
+
only_if=[Tool("Bash"), UnpipedGrep()],
|
|
47
|
+
message="BLOCKED: Use ripgrep (rg) instead of grep. Replace grep with rg, or use the built-in Grep tool.",
|
|
48
|
+
block=True,
|
|
49
|
+
tests={
|
|
50
|
+
Input(command="grep -rn foo src/"): Block(),
|
|
51
|
+
Input(command="ls | grep foo"): Allow(),
|
|
52
|
+
Input(command="cat x | grep foo | sort"): Allow(),
|
|
53
|
+
Input(command="grep foo file.py | wc -l"): Block(),
|
|
54
|
+
Input(command="grep foo a && echo done"): Block(),
|
|
55
|
+
Input(command="git log --grep=fix"): Allow(),
|
|
56
|
+
Input(command='git log --grep "fix bug"'): Allow(),
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Requires the codex plugin (/plugin install codex@skills from yasyf/cc-skills).
|
|
61
|
+
# Delete this nudge if you don't use Codex.
|
|
62
|
+
nudge(
|
|
63
|
+
"""
|
|
64
|
+
Multiple tool failures detected without a /codex invocation. After 2 failed
|
|
65
|
+
approaches, get a second opinion from `/codex` before attempting a 3rd —
|
|
66
|
+
Codex catches errors that Claude may miss.
|
|
67
|
+
""",
|
|
68
|
+
skip_if=[UsedSkill("codex|codex:codex")],
|
|
69
|
+
events=Event.PostToolUseFailure,
|
|
70
|
+
when=lambda evt: evt.ctx.turn.count_failures() >= 2 and not evt.ctx.t.has_command(r"codex"),
|
|
71
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from captain_hook import Allow, FilePath, Input, Tool, UsedSkill, Warn, nudge
|
|
4
|
+
|
|
5
|
+
# Advisory reminder to consult the writing-docs skill (and run slop-cop) before
|
|
6
|
+
# editing documentation. Fires once per session on the first doc edit and stands
|
|
7
|
+
# down once the skill has been used. Advisory only, so it never blocks an edit.
|
|
8
|
+
#
|
|
9
|
+
# The scaffolded .claude/settings.json registers the yasyf/cc-skills marketplace
|
|
10
|
+
# and enables writing-docs@skills, so the skill (and the skip_if check) activates
|
|
11
|
+
# when the folder is trusted — no manual /plugin install.
|
|
12
|
+
nudge(
|
|
13
|
+
"You're editing documentation. Consult the writing-docs skill first for the "
|
|
14
|
+
"Diataxis modes, voice rules, and code-sample rules, then run "
|
|
15
|
+
"`slop-cop check <file> --lang=markdown` to catch prose tells before you finish.",
|
|
16
|
+
only_if=[Tool("Write|Edit"), FilePath("**/*.md", "**/*.qmd", "docs/**", "README.md")],
|
|
17
|
+
skip_if=[UsedSkill("writing-docs|writing-docs:writing-docs")],
|
|
18
|
+
max_fires=1,
|
|
19
|
+
tests={
|
|
20
|
+
Input(tool="Write", file="docs/guide/x.qmd", content="# X"): Warn(pattern="writing-docs"),
|
|
21
|
+
Input(tool="Edit", file="src/app.py", content="x = 1"): Allow(),
|
|
22
|
+
},
|
|
23
|
+
)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from captain_hook import Allow, BaseHookEvent, Block, CustomCondition, Event, Input, Tool, hook
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RewritingExistingPlan(CustomCondition):
|
|
7
|
+
"""True when a Write targets a plan file (`.md` under `plans/` or `specs/`) that was
|
|
8
|
+
already written earlier this session, with no new plan cycle (EnterPlanMode) since the
|
|
9
|
+
last Write to it.
|
|
10
|
+
|
|
11
|
+
Reads from ``evt.ctx.prior`` (the window before the current turn's last exchange) so the
|
|
12
|
+
pending Write being evaluated is never itself counted as the prior edit. A write to the
|
|
13
|
+
file this session already implies it exists, so no filesystem check is needed.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def check(self, evt: BaseHookEvent) -> bool:
|
|
17
|
+
fp = evt.file
|
|
18
|
+
if not fp or fp.suffix != ".md" or not fp.under("plans/", "specs/"):
|
|
19
|
+
return False
|
|
20
|
+
if not evt.ctx.prior.has_edit_to(str(fp)):
|
|
21
|
+
return False
|
|
22
|
+
return not evt.ctx.prior.after(tool="Write", file=str(fp)).has_tool("EnterPlanMode")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
hook(
|
|
26
|
+
Event.PreToolUse,
|
|
27
|
+
only_if=[Tool("Write"), RewritingExistingPlan()],
|
|
28
|
+
message=(
|
|
29
|
+
"This plan file was already written in this planning session. Use the Edit tool "
|
|
30
|
+
"to make incremental changes instead of rewriting the entire plan with Write."
|
|
31
|
+
),
|
|
32
|
+
block=True,
|
|
33
|
+
tests={
|
|
34
|
+
# Rewriting a plan already written this session, no new plan cycle since -> block.
|
|
35
|
+
Input(
|
|
36
|
+
tool="Write",
|
|
37
|
+
file="/x/plans/p.md",
|
|
38
|
+
content="# Plan v2",
|
|
39
|
+
transcript=[
|
|
40
|
+
{"type": "assistant", "message": {"content": [
|
|
41
|
+
{"type": "tool_use", "name": "Write", "id": "w0",
|
|
42
|
+
"input": {"file_path": "/x/plans/p.md", "content": "# Plan v1"}}]}},
|
|
43
|
+
{"type": "assistant", "message": {"content": [
|
|
44
|
+
{"type": "tool_use", "name": "Write", "id": "w1",
|
|
45
|
+
"input": {"file_path": "/x/plans/p.md", "content": "# Plan v2"}}]}},
|
|
46
|
+
],
|
|
47
|
+
): Block(),
|
|
48
|
+
# A new plan cycle (EnterPlanMode) started since the last write -> allow the rewrite.
|
|
49
|
+
Input(
|
|
50
|
+
tool="Write",
|
|
51
|
+
file="/x/plans/p.md",
|
|
52
|
+
content="# Plan v2",
|
|
53
|
+
transcript=[
|
|
54
|
+
{"type": "assistant", "message": {"content": [
|
|
55
|
+
{"type": "tool_use", "name": "Write", "id": "w0",
|
|
56
|
+
"input": {"file_path": "/x/plans/p.md", "content": "# Plan v1"}}]}},
|
|
57
|
+
{"type": "assistant", "message": {"content": [
|
|
58
|
+
{"type": "tool_use", "name": "EnterPlanMode", "id": "p1", "input": {}}]}},
|
|
59
|
+
{"type": "assistant", "message": {"content": [
|
|
60
|
+
{"type": "tool_use", "name": "Write", "id": "w1",
|
|
61
|
+
"input": {"file_path": "/x/plans/p.md", "content": "# Plan v2"}}]}},
|
|
62
|
+
],
|
|
63
|
+
): Allow(),
|
|
64
|
+
# First write of this plan this session -> allow.
|
|
65
|
+
Input(tool="Write", file="/x/plans/p.md", content="# Plan", transcript=[]): Allow(),
|
|
66
|
+
# Not a plan file -> allow.
|
|
67
|
+
Input(tool="Write", file="/x/src/main.py", content="x = 1"): Allow(),
|
|
68
|
+
},
|
|
69
|
+
)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from captain_hook import Allow, Content, Input, Tool, UsedSkill, Warn, nudge
|
|
4
|
+
|
|
5
|
+
PROMPT_MARKERS = (
|
|
6
|
+
r"<instruction>|<system>|<examples>|<success_criteria>|<output_format>|"
|
|
7
|
+
r"<key_constraints>|<reasoning_framework>|<action_rules>|<preferred_patterns>|"
|
|
8
|
+
r"<persona>|<role>|<tool_persistence>|<completeness_contract>|<verification_loop>|"
|
|
9
|
+
r"You are an?\b|Your task is to\b|You will be provided with\b|"
|
|
10
|
+
r"def\s+\w*prompt\s*\(|(?i:\b(?:system|developer) prompt\b)|"
|
|
11
|
+
r"messages\s*=\s*\[|"
|
|
12
|
+
r"""["']role["']\s*:\s*["'](?:system|user|assistant|developer)["']"""
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
nudge(
|
|
16
|
+
"You're editing LLM prompt content. The `llm-prompts` skill covers positive framing, "
|
|
17
|
+
"XML structure, the prompting principles, and current per-provider model behaviors "
|
|
18
|
+
"(Claude, GPT-5.x, Gemini) — consult it before writing prompts. After editing, run "
|
|
19
|
+
"`/slop-cop-check` on the file to surface LLM-generated writing tells (overused "
|
|
20
|
+
"intensifiers, hedge stacks, em-dash pivots, throat-clearing) and revise any real hits.",
|
|
21
|
+
only_if=[Tool("Edit|Write"), Content(PROMPT_MARKERS, project_only=False)],
|
|
22
|
+
skip_if=[
|
|
23
|
+
UsedSkill("llm-prompts|llm-prompts:llm-prompts"),
|
|
24
|
+
UsedSkill("slop-cop:slop-cop-check|slop-cop:slop-cop-prose|slop-cop-check|slop-cop-prose"),
|
|
25
|
+
],
|
|
26
|
+
tests={
|
|
27
|
+
Input(
|
|
28
|
+
file="agent.py", content='messages = [{"role": "system", "content": "You are a helpful assistant."}]\n'
|
|
29
|
+
): Warn(),
|
|
30
|
+
Input(file="prompt.md", content="<instruction>\nSummarize the document.\n</instruction>\n"): Warn(),
|
|
31
|
+
Input(file="util.py", content="def add(a, b):\n return a + b\n"): Allow(),
|
|
32
|
+
},
|
|
33
|
+
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from captain_hook import Allow, BaseHookEvent, Block, CustomCondition, Event, Input, Waiting, gate
|
|
4
|
+
|
|
5
|
+
# Prose and config file extensions that shouldn't, on their own, demand a code-review pass.
|
|
6
|
+
# Tailor this (and the excluded dirs below) to scope what counts as "source" for your repo.
|
|
7
|
+
NON_SOURCE_SUFFIXES = (
|
|
8
|
+
".md", ".mdx", ".rst", ".txt", ".json", ".toml",
|
|
9
|
+
".yaml", ".yml", ".ini", ".cfg", ".lock",
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EditedSource(CustomCondition):
|
|
14
|
+
"""True when the session edited a non-test source file (docs and config excluded)."""
|
|
15
|
+
|
|
16
|
+
def check(self, evt: BaseHookEvent) -> bool:
|
|
17
|
+
return any(
|
|
18
|
+
not f.is_test
|
|
19
|
+
and f.suffix not in NON_SOURCE_SUFFIXES
|
|
20
|
+
and not f.under("docs", ".claude", ".github")
|
|
21
|
+
for f in evt.ctx.t.tool_calls.named("Edit|Write").files()
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
gate(
|
|
26
|
+
"You changed source files but haven't done a review pass. Before stopping, review your "
|
|
27
|
+
"changes for correctness and against STYLEGUIDE.md, and fix any issues in the code you "
|
|
28
|
+
"wrote. See: STYLEGUIDE.md.",
|
|
29
|
+
only_if=[EditedSource()],
|
|
30
|
+
skip_if=[Waiting()],
|
|
31
|
+
events=Event.Stop,
|
|
32
|
+
tests={
|
|
33
|
+
Input(transcript=[
|
|
34
|
+
{"type": "assistant", "message": {"content": [
|
|
35
|
+
{"type": "tool_use", "name": "Edit", "id": "e1",
|
|
36
|
+
"input": {"file_path": "src/app.py", "old_string": "a", "new_string": "b"}}]}},
|
|
37
|
+
]): Block(),
|
|
38
|
+
Input(transcript=[
|
|
39
|
+
{"type": "assistant", "message": {"content": [
|
|
40
|
+
{"type": "tool_use", "name": "Edit", "id": "e1",
|
|
41
|
+
"input": {"file_path": "README.md", "old_string": "a", "new_string": "b"}}]}},
|
|
42
|
+
]): Allow(),
|
|
43
|
+
},
|
|
44
|
+
)
|