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.
Files changed (90) hide show
  1. {capt_hook-3.1.0 → capt_hook-3.3.0}/PKG-INFO +21 -15
  2. {capt_hook-3.1.0 → capt_hook-3.3.0}/README.md +20 -14
  3. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/cli.py +127 -29
  4. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/loader.py +18 -0
  5. capt_hook-3.3.0/captain_hook/packs/__init__.py +1 -0
  6. capt_hook-3.3.0/captain_hook/packs/general/capt-hook.toml +4 -0
  7. capt_hook-3.3.0/captain_hook/packs/general/commands.py +71 -0
  8. capt_hook-3.3.0/captain_hook/packs/general/docs.py +23 -0
  9. capt_hook-3.3.0/captain_hook/packs/general/plans.py +69 -0
  10. capt_hook-3.3.0/captain_hook/packs/general/prompts.py +33 -0
  11. capt_hook-3.3.0/captain_hook/packs/general/review.py +44 -0
  12. capt_hook-3.3.0/captain_hook/packs/general/stewardship.py +269 -0
  13. capt_hook-3.3.0/captain_hook/packs/general/tasks.py +147 -0
  14. capt_hook-3.3.0/captain_hook/packs/manager.py +256 -0
  15. capt_hook-3.3.0/captain_hook/packs/python/capt-hook.toml +4 -0
  16. capt_hook-3.3.0/captain_hook/packs/python/style.py +147 -0
  17. capt_hook-3.3.0/captain_hook/packs/python/testing.py +87 -0
  18. capt_hook-3.3.0/captain_hook/packs/python/toolchain.py +32 -0
  19. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/review/cli.py +16 -9
  20. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/review/settings.py +1 -1
  21. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +28 -19
  22. {capt_hook-3.1.0 → capt_hook-3.3.0}/pyproject.toml +1 -1
  23. {capt_hook-3.1.0 → capt_hook-3.3.0}/LICENSE +0 -0
  24. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/.claude-plugin/plugin.json +0 -0
  25. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/__init__.py +0 -0
  26. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/__main__.py +0 -0
  27. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/app.py +0 -0
  28. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/classifiers/__init__.py +0 -0
  29. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/classifiers/conductor.py +0 -0
  30. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/classifiers/droid.py +0 -0
  31. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/classifiers/native.py +0 -0
  32. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/command.py +0 -0
  33. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/conditions.py +0 -0
  34. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/context.py +0 -0
  35. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/decisions.py +0 -0
  36. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/dispatch.py +0 -0
  37. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/events.py +0 -0
  38. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/file.py +0 -0
  39. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/llm/__init__.py +0 -0
  40. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/log.py +0 -0
  41. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/primitives/__init__.py +0 -0
  42. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/primitives/commands.py +0 -0
  43. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/primitives/lint.py +0 -0
  44. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/primitives/llm.py +0 -0
  45. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/primitives/nudge.py +0 -0
  46. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/primitives/workflow.py +0 -0
  47. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/prompt.py +0 -0
  48. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/py.typed +0 -0
  49. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/review/__init__.py +0 -0
  50. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/review/fix.py +0 -0
  51. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/review/formats.py +0 -0
  52. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/review/judge.py +0 -0
  53. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/review/pipeline.py +0 -0
  54. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/review/repo.py +0 -0
  55. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/review/scan.py +0 -0
  56. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/review/store.py +0 -0
  57. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/review/sync.py +0 -0
  58. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/session.py +0 -0
  59. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/settings.py +0 -0
  60. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/signals/__init__.py +0 -0
  61. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/signals/nlp.py +0 -0
  62. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/skills/authoring-hooks/SKILL.md +0 -0
  63. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +0 -0
  64. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +0 -0
  65. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
  66. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +0 -0
  67. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/skills/scanning-sessions/SKILL.md +0 -0
  68. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
  69. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
  70. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
  71. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
  72. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
  73. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
  74. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/state.py +0 -0
  75. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/style/__init__.py +0 -0
  76. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/style/matchers.py +0 -0
  77. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/style/scope.py +0 -0
  78. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/style/types.py +0 -0
  79. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/tasks.py +0 -0
  80. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
  81. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/testing/__init__.py +0 -0
  82. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/testing/helpers.py +0 -0
  83. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/testing/session_cache.py +0 -0
  84. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/testing/types.py +0 -0
  85. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/tests/__init__.py +0 -0
  86. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/tests/helpers.py +0 -0
  87. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/types.py +0 -0
  88. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/util/__init__.py +0 -0
  89. {capt_hook-3.1.0 → capt_hook-3.3.0}/captain_hook/util/model_cache.py +0 -0
  90. {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.1.0
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
- ## Install
58
+ ## Quickstart
59
59
 
60
- There's no install step. Run everything through [uvx](https://docs.astral.sh/uv/).
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
- `uvx` fetches captain-hook into a throwaway environment and runs it, so you never add it to `pyproject.toml`. Every command in this README works the same way once you prefix it with `uvx`.
68
+ **From inside Claude Code** install the plugin, then ask Claude to set it up:
67
69
 
68
- ## First hook
70
+ ```
71
+ /plugin marketplace add yasyf/captain-hook
72
+ /plugin install captain-hook@captain-hook
73
+ ```
69
74
 
70
- `uvx capt-hook init` scaffolds `.claude/hooks/`, wires Claude Code's settings, and installs the skills. One command and you're live.
75
+ > set up captain hook
71
76
 
72
- ```bash
73
- uvx capt-hook init
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
- ## Agent Skills & plugin
125
+ ## Session reviewer
121
126
 
122
- 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` mines your repo's docs, CI, and git history into proposed gates and nudges. `translating-styleguides` turns a STYLEGUIDE.md into enforced rules. `uvx capt-hook init` installs both into `.claude/skills/`, or you can add them as a plugin.
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
- /plugin marketplace add yasyf/captain-hook
126
- /plugin install captain-hook@captain-hook
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
- ## Install
12
+ ## Quickstart
13
13
 
14
- There's no install step. Run everything through [uvx](https://docs.astral.sh/uv/).
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
- `uvx` fetches captain-hook into a throwaway environment and runs it, so you never add it to `pyproject.toml`. Every command in this README works the same way once you prefix it with `uvx`.
22
+ **From inside Claude Code** install the plugin, then ask Claude to set it up:
21
23
 
22
- ## First hook
24
+ ```
25
+ /plugin marketplace add yasyf/captain-hook
26
+ /plugin install captain-hook@captain-hook
27
+ ```
23
28
 
24
- `uvx capt-hook init` scaffolds `.claude/hooks/`, wires Claude Code's settings, and installs the skills. One command and you're live.
29
+ > set up captain hook
25
30
 
26
- ```bash
27
- uvx capt-hook init
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
- ## Agent Skills & plugin
79
+ ## Session reviewer
75
80
 
76
- 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` mines your repo's docs, CI, and git history into proposed gates and nudges. `translating-styleguides` turns a STYLEGUIDE.md into enforced rules. `uvx capt-hook init` installs both into `.claude/skills/`, or you can add them as a plugin.
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
- /plugin marketplace add yasyf/captain-hook
80
- /plugin install captain-hook@captain-hook
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
- print(f"{label}:")
199
+ click.echo(f"{label}:")
191
200
  if not summary:
192
- print(" no hook entries")
201
+ click.echo(" no hook entries")
193
202
  for event in by_status["added"]:
194
- print(f" + added {event}")
203
+ click.echo(f" + added {event}")
195
204
  for event in by_status["updated"]:
196
- print(f" ~ updated {event}")
205
+ click.echo(f" ~ updated {event}")
197
206
  for event in by_status["removed"]:
198
- print(f" - removed {event}")
207
+ click.echo(f" - removed {event}")
199
208
  if unchanged := by_status["unchanged"]:
200
- print(f" unchanged: {', '.join(unchanged)} (already present)")
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
- example_created = not example.exists()
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
- reset()
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
- print(f"Scaffolded {example.relative_to(root)} + {settings_path.relative_to(root)}.")
307
- print()
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
- print()
310
- print(".claude/skills/:")
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
- print(f" + installed {name}")
330
+ click.echo(f" + installed {name}")
313
331
  if skipped := [n for n, status in skills_summary.items() if status == "skipped"]:
314
- print(f" unchanged: {', '.join(skipped)} (already present; capt-hook skills install --force to refresh)")
315
- print()
316
- print("Next:")
317
- print(" 1. Read the quickstart: https://yasyf.github.io/captain-hook/")
318
- print(" 2. Edit example.py or add new files under .claude/hooks/")
319
- print(" 3. uvx capt-hook test # verify inline tests")
320
- print(" 4. uvx capt-hook register-hooks # re-register after adding events")
321
- print(" 5. /bootstrapping-hooks in Claude # mine hooks from this repo's conventions")
322
- print()
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 wire settings."""
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,4 @@
1
+ name = "general"
2
+ version = "0.1.0"
3
+ description = "Language-agnostic guards: git/command safety, code stewardship, doc & prompt nudges, task & plan discipline, and a code-review gate."
4
+ hooks = "."
@@ -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
+ )