capt-hook 2.0.0__tar.gz → 2.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. {capt_hook-2.0.0 → capt_hook-2.1.0}/PKG-INFO +32 -41
  2. capt_hook-2.1.0/README.md +89 -0
  3. capt_hook-2.1.0/captain_hook/__init__.py +95 -0
  4. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/app.py +3 -4
  5. capt_hook-2.1.0/captain_hook/ast_grep.py +136 -0
  6. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/cli.py +217 -85
  7. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/command.py +31 -0
  8. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/conditions.py +10 -2
  9. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/context.py +33 -21
  10. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/decisions.py +7 -2
  11. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/dispatch.py +10 -1
  12. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/events.py +47 -1
  13. capt_hook-2.1.0/captain_hook/loader.py +122 -0
  14. capt_hook-2.1.0/captain_hook/packs/__init__.py +1 -0
  15. capt_hook-2.1.0/captain_hook/packs/general/__init__.py +1 -0
  16. capt_hook-2.1.0/captain_hook/packs/general/capt-hook.toml +4 -0
  17. capt_hook-2.1.0/captain_hook/packs/general/commands.py +40 -0
  18. capt_hook-2.1.0/captain_hook/packs/general/docs.py +25 -0
  19. capt_hook-2.1.0/captain_hook/packs/general/plans.py +111 -0
  20. capt_hook-2.1.0/captain_hook/packs/general/prompts.py +33 -0
  21. capt_hook-2.1.0/captain_hook/packs/general/review.py +75 -0
  22. capt_hook-2.1.0/captain_hook/packs/general/tasks.py +147 -0
  23. capt_hook-2.1.0/captain_hook/packs/go/__init__.py +1 -0
  24. capt_hook-2.1.0/captain_hook/packs/go/capt-hook.toml +4 -0
  25. capt_hook-2.1.0/captain_hook/packs/go/testing.py +87 -0
  26. capt_hook-2.1.0/captain_hook/packs/go/toolchain.py +24 -0
  27. capt_hook-2.1.0/captain_hook/packs/manager.py +451 -0
  28. capt_hook-2.1.0/captain_hook/packs/python/__init__.py +1 -0
  29. capt_hook-2.1.0/captain_hook/packs/python/capt-hook.toml +4 -0
  30. capt_hook-2.1.0/captain_hook/packs/python/style.py +157 -0
  31. capt_hook-2.1.0/captain_hook/packs/python/testing.py +87 -0
  32. capt_hook-2.1.0/captain_hook/packs/python/toolchain.py +19 -0
  33. capt_hook-2.1.0/captain_hook/packs/steering/__init__.py +25 -0
  34. capt_hook-2.1.0/captain_hook/packs/steering/capt-hook.toml +4 -0
  35. capt_hook-2.1.0/captain_hook/packs/steering/lib.py +104 -0
  36. capt_hook-2.1.0/captain_hook/packs/steering/steering.py +191 -0
  37. capt_hook-2.1.0/captain_hook/primitives/__init__.py +15 -0
  38. capt_hook-2.1.0/captain_hook/primitives/commands.py +139 -0
  39. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/primitives/lint.py +54 -5
  40. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/primitives/nudge.py +3 -1
  41. capt_hook-2.1.0/captain_hook/primitives/rewrite.py +64 -0
  42. capt_hook-2.1.0/captain_hook/review/__init__.py +3 -0
  43. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/review/cli.py +72 -17
  44. capt_hook-2.1.0/captain_hook/review/dashboard.py +216 -0
  45. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/review/formats.py +1 -3
  46. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/review/judge.py +2 -2
  47. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/review/pipeline.py +26 -8
  48. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/review/scan.py +36 -2
  49. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/review/settings.py +1 -1
  50. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/review/store.py +147 -21
  51. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/review/sync.py +5 -2
  52. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/settings.py +2 -2
  53. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/signals/nlp.py +7 -3
  54. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/skills/authoring-hooks/SKILL.md +3 -3
  55. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +28 -3
  56. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +1 -1
  57. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +1 -1
  58. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +29 -23
  59. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/skills/scanning-sessions/SKILL.md +3 -3
  60. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +5 -4
  61. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/skills/translating-styleguides/SKILL.md +1 -1
  62. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/state.py +30 -18
  63. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/style/__init__.py +16 -38
  64. capt_hook-2.1.0/captain_hook/style/ast_grep.py +116 -0
  65. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/style/types.py +37 -4
  66. capt_hook-2.1.0/captain_hook/testing/__init__.py +1 -0
  67. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/testing/helpers.py +43 -8
  68. capt_hook-2.1.0/captain_hook/testing/types.py +147 -0
  69. capt_hook-2.1.0/captain_hook/tests/__init__.py +13 -0
  70. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/tests/helpers.py +4 -4
  71. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/types.py +106 -2
  72. capt_hook-2.1.0/captain_hook/util/http.py +133 -0
  73. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/util/model_cache.py +7 -6
  74. capt_hook-2.1.0/captain_hook/utils.py +47 -0
  75. {capt_hook-2.0.0 → capt_hook-2.1.0}/pyproject.toml +12 -3
  76. capt_hook-2.0.0/README.md +0 -100
  77. capt_hook-2.0.0/captain_hook/__init__.py +0 -89
  78. capt_hook-2.0.0/captain_hook/loader.py +0 -63
  79. capt_hook-2.0.0/captain_hook/primitives/__init__.py +0 -15
  80. capt_hook-2.0.0/captain_hook/primitives/commands.py +0 -62
  81. capt_hook-2.0.0/captain_hook/review/__init__.py +0 -6
  82. capt_hook-2.0.0/captain_hook/testing/__init__.py +0 -6
  83. capt_hook-2.0.0/captain_hook/testing/types.py +0 -90
  84. capt_hook-2.0.0/captain_hook/tests/__init__.py +0 -11
  85. capt_hook-2.0.0/captain_hook/utils.py +0 -27
  86. {capt_hook-2.0.0 → capt_hook-2.1.0}/LICENSE +0 -0
  87. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/.claude-plugin/plugin.json +0 -0
  88. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/__main__.py +0 -0
  89. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/classifiers/__init__.py +0 -0
  90. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/classifiers/conductor.py +0 -0
  91. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/classifiers/droid.py +0 -0
  92. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/classifiers/native.py +0 -0
  93. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/file.py +0 -0
  94. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/llm/__init__.py +0 -0
  95. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/log.py +0 -0
  96. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/primitives/llm.py +0 -0
  97. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/primitives/workflow.py +0 -0
  98. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/prompt.py +0 -0
  99. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/py.typed +0 -0
  100. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/review/fix.py +0 -0
  101. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/review/repo.py +0 -0
  102. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/session.py +0 -0
  103. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/signals/__init__.py +0 -0
  104. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +0 -0
  105. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
  106. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
  107. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
  108. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
  109. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/style/matchers.py +0 -0
  110. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/style/scope.py +0 -0
  111. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/tasks.py +0 -0
  112. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
  113. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/testing/session_cache.py +0 -0
  114. {capt_hook-2.0.0 → capt_hook-2.1.0}/captain_hook/util/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: capt-hook
3
- Version: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: Declarative hook framework for Claude Code
5
5
  Keywords: claude,claude-code,hooks,llm,agents,guardrails,cli
6
6
  Author: Yasyf Mohamedali
@@ -16,20 +16,22 @@ Classifier: Programming Language :: Python :: 3 :: Only
16
16
  Classifier: Topic :: Software Development :: Quality Assurance
17
17
  Classifier: Topic :: Software Development :: Testing
18
18
  Classifier: Typing :: Typed
19
- Requires-Dist: cc-transcript>=2.0,<3
19
+ Requires-Dist: cc-transcript>=5,<6
20
20
  Requires-Dist: pydantic>=2.0
21
21
  Requires-Dist: pydantic-settings>=2.0
22
22
  Requires-Dist: tree-sitter>=0.24
23
23
  Requires-Dist: tree-sitter-bash>=0.23
24
+ Requires-Dist: ast-grep-py>=0.39,<1
24
25
  Requires-Dist: funcy>=2.0
25
26
  Requires-Dist: spacy>=3.7
26
27
  Requires-Dist: click>=8
28
+ Requires-Dist: rich>=13
27
29
  Requires-Dist: orjsonl>=1.0
28
30
  Requires-Dist: wn>=1.1.0
29
31
  Requires-Dist: lazy-object-proxy>=1.12.0
30
32
  Requires-Dist: filelock>=3
31
33
  Requires-Dist: loguru>=0.7.3
32
- Requires-Dist: spawnllm>=0.1.3
34
+ Requires-Dist: spawnllm>=0.4.0
33
35
  Requires-Dist: pytest>=8.0 ; extra == 'dev'
34
36
  Requires-Dist: pytest-asyncio>=0.24 ; extra == 'dev'
35
37
  Requires-Dist: pyright>=1.1 ; extra == 'dev'
@@ -53,48 +55,48 @@ Description-Content-Type: text/markdown
53
55
  [![Docs](https://github.com/yasyf/captain-hook/actions/workflows/docs.yml/badge.svg)](https://yasyf.github.io/captain-hook/)
54
56
  [![License: PolyForm Noncommercial](https://img.shields.io/badge/License-PolyForm_Noncommercial_1.0.0-blue.svg)](https://github.com/yasyf/captain-hook/blob/main/LICENSE)
55
57
 
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.
58
+ Guardrails for Claude Code, written as typed, testable data and learned from the corrections you give Claude.
59
+
60
+ A captain-hook hook is declarative Python: an event, some conditions, an action. Block a footgun before it runs, nudge the agent off a bad pattern, gate "done" until the tests pass. Then captain-hook closes the loop: it reads the corrections you give Claude as you work and opens pull requests that codify the durable ones as new hooks. You write the first few; it writes the rest.
57
61
 
58
62
  ## Install
59
63
 
60
- There's no install step. Run everything through [uvx](https://docs.astral.sh/uv/).
64
+ captain-hook needs no install it runs through [uvx](https://docs.astral.sh/uv/). From your project root:
61
65
 
62
66
  ```bash
63
67
  uvx capt-hook init
64
68
  ```
65
69
 
66
- `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`.
67
-
68
- ## First hook
70
+ `init` scaffolds `.claude/hooks/`, wires Claude Code's settings, registers the captain-hook plugin so its skills install on workspace-trust, and arms the [session reviewer](#it-learns-from-your-corrections). Or do it all from a session. Run `/plugin marketplace add yasyf/captain-hook`, then ask Claude to "set up captain hook".
69
71
 
70
- `uvx capt-hook init` scaffolds `.claude/hooks/`, wires Claude Code's settings, and installs the skills. One command and you're live.
72
+ ## Your first hook
71
73
 
72
- ```bash
73
- uvx capt-hook init
74
- ```
75
-
76
- A hook is declarative Python with an event, some conditions, and an action. This one stops the agent from finishing a UI change it never looked at.
74
+ A hook is an event, some conditions, and an action. This one stops the agent from finishing a UI change it never looked at:
77
75
 
78
76
  ```python
79
77
  # .claude/hooks/visual_review.py
80
78
  from captain_hook import gate, TouchedFile, UsedSkill
81
79
 
82
- # A Stop gate: before the agent finishes, block if it edited UI files without doing a visual review.
83
80
  gate(
84
- # the one-line reason shown to the agent when the gate fires
85
81
  "You edited UI files. Open them with agent-browser and verify they render before finishing.",
86
- # fires only if UI files changed
87
82
  only_if=[TouchedFile("**/src/routes/**", "**/src/components/**")],
88
- # already reviewed -> don't block
89
83
  skip_if=[UsedSkill("agent-browser")],
90
84
  )
91
85
  ```
92
86
 
93
- Conditions match tools, files, commands, and even which skills the agent used.
87
+ `only_if` arms the gate only when UI files changed; `skip_if` stands it down once the agent has done the review. Conditions match tools, files, commands, and even which skills the agent used.
88
+
89
+ ## It learns from your corrections
90
+
91
+ Most hooks you'll never write by hand.
92
+
93
+ The corrections you give Claude as you work are exactly the rules a hook should enforce: "never force-push", "use `uv`, not `pip`", "you weakened that test". Writing the hook by hand is friction you skip in the moment, so the **session reviewer** notices for you. When a session ends, it reads the transcript, finds the durable corrections and the hooks that misfired, judges which ones are standing rules and which are one-offs, and once a pattern proves itself across sessions, opens a pull request that adds the hook — or fixes the one that misfired. You review the PR like any other.
94
94
 
95
- ## Test your hooks
95
+ It's on by default after `init`. Turn it off for a repo with `uvx capt-hook review disable`. The [session reviewer guide](https://yasyf.github.io/captain-hook/docs/guide/session-reviewer.html) covers the prerequisites (an authenticated `claude` and `gh`) and the `HOOKS_REVIEW_*` thresholds.
96
96
 
97
- 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`.
97
+ ## Tested like code
98
+
99
+ Every deterministic hook carries inline tests, so a broken hook fails like broken code:
98
100
 
99
101
  ```python
100
102
  # .claude/hooks/safety.py
@@ -111,35 +113,24 @@ block_command(
111
113
  )
112
114
  ```
113
115
 
116
+ Run them from your project root, where `--hooks` defaults to `.claude/hooks`:
117
+
114
118
  ```bash
115
119
  uvx capt-hook test
116
120
  ```
117
121
 
118
- `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
-
120
- ## Agent Skills & plugin
122
+ Wire that into CI and you catch a broken hook the way you catch broken code.
121
123
 
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.
124
+ ## What it's for
123
125
 
124
- ```
125
- /plugin marketplace add yasyf/captain-hook
126
- /plugin install captain-hook@captain-hook
127
- ```
128
-
129
- ## What this solves
130
-
131
- captain-hook covers these jobs:
132
-
133
- - Block dangerous tool calls before they execute on `PreToolUse`, like force-push, package-manager footguns, and raw `rm -rf`.
134
- - Drive the agent with feedback that fires on the patterns it actually emits, such as repeated failures, weakened tests, and missed conventions.
135
- - Enforce multi-step workflows with Stop gates and artifact validation, so the agent can't declare "done" without running tests, writing a report, or completing a checklist.
136
- - Keep all of the above testable. Every hook ships with inline `tests = {...}` that `uvx capt-hook test` runs in CI, so you catch broken hooks the way you catch broken code.
126
+ - Block footguns before they run on `PreToolUse`: force-push, `rm -rf`, package-manager traps.
127
+ - Steer the agent with feedback that fires on the patterns it actually emits: repeated failures, weakened tests, missed conventions.
128
+ - Hold the line on multi-step work with Stop gates and artifact checks, so the agent can't call it "done" before the tests run or the report's written.
129
+ - Keep all of it testable; every hook ships with inline tests that run in CI.
137
130
 
138
131
  ## Docs
139
132
 
140
- [Read the docs](https://yasyf.github.io/captain-hook/) for the full guide to conditions, primitives, LLM hooks, workflows, state, and real-world patterns.
141
-
142
- For working on captain-hook itself, see the [development guide](https://yasyf.github.io/captain-hook/docs/development/).
133
+ [Read the docs](https://yasyf.github.io/captain-hook/) for the full guide to conditions, primitives, LLM hooks, workflows, state, and real-world patterns. To work on captain-hook itself, see the [development guide](https://yasyf.github.io/captain-hook/docs/development/).
143
134
 
144
135
  ## License
145
136
 
@@ -0,0 +1,89 @@
1
+ # captain-hook
2
+
3
+ ![captain-hook banner](https://github.com/yasyf/captain-hook/raw/main/docs/assets/readme-banner.webp)
4
+
5
+ [![PyPI](https://img.shields.io/pypi/v/capt-hook.svg)](https://pypi.org/project/capt-hook/)
6
+ [![Python](https://img.shields.io/pypi/pyversions/capt-hook.svg)](https://pypi.org/project/capt-hook/)
7
+ [![Docs](https://github.com/yasyf/captain-hook/actions/workflows/docs.yml/badge.svg)](https://yasyf.github.io/captain-hook/)
8
+ [![License: PolyForm Noncommercial](https://img.shields.io/badge/License-PolyForm_Noncommercial_1.0.0-blue.svg)](https://github.com/yasyf/captain-hook/blob/main/LICENSE)
9
+
10
+ Guardrails for Claude Code, written as typed, testable data — and learned from the corrections you give Claude.
11
+
12
+ A captain-hook hook is declarative Python: an event, some conditions, an action. Block a footgun before it runs, nudge the agent off a bad pattern, gate "done" until the tests pass. Then captain-hook closes the loop: it reads the corrections you give Claude as you work and opens pull requests that codify the durable ones as new hooks. You write the first few; it writes the rest.
13
+
14
+ ## Install
15
+
16
+ captain-hook needs no install — it runs through [uvx](https://docs.astral.sh/uv/). From your project root:
17
+
18
+ ```bash
19
+ uvx capt-hook init
20
+ ```
21
+
22
+ `init` scaffolds `.claude/hooks/`, wires Claude Code's settings, registers the captain-hook plugin so its skills install on workspace-trust, and arms the [session reviewer](#it-learns-from-your-corrections). Or do it all from a session. Run `/plugin marketplace add yasyf/captain-hook`, then ask Claude to "set up captain hook".
23
+
24
+ ## Your first hook
25
+
26
+ A hook is an event, some conditions, and an action. This one stops the agent from finishing a UI change it never looked at:
27
+
28
+ ```python
29
+ # .claude/hooks/visual_review.py
30
+ from captain_hook import gate, TouchedFile, UsedSkill
31
+
32
+ gate(
33
+ "You edited UI files. Open them with agent-browser and verify they render before finishing.",
34
+ only_if=[TouchedFile("**/src/routes/**", "**/src/components/**")],
35
+ skip_if=[UsedSkill("agent-browser")],
36
+ )
37
+ ```
38
+
39
+ `only_if` arms the gate only when UI files changed; `skip_if` stands it down once the agent has done the review. Conditions match tools, files, commands, and even which skills the agent used.
40
+
41
+ ## It learns from your corrections
42
+
43
+ Most hooks you'll never write by hand.
44
+
45
+ The corrections you give Claude as you work are exactly the rules a hook should enforce: "never force-push", "use `uv`, not `pip`", "you weakened that test". Writing the hook by hand is friction you skip in the moment, so the **session reviewer** notices for you. When a session ends, it reads the transcript, finds the durable corrections and the hooks that misfired, judges which ones are standing rules and which are one-offs, and once a pattern proves itself across sessions, opens a pull request that adds the hook — or fixes the one that misfired. You review the PR like any other.
46
+
47
+ It's on by default after `init`. Turn it off for a repo with `uvx capt-hook review disable`. The [session reviewer guide](https://yasyf.github.io/captain-hook/docs/guide/session-reviewer.html) covers the prerequisites (an authenticated `claude` and `gh`) and the `HOOKS_REVIEW_*` thresholds.
48
+
49
+ ## Tested like code
50
+
51
+ Every deterministic hook carries inline tests, so a broken hook fails like broken code:
52
+
53
+ ```python
54
+ # .claude/hooks/safety.py
55
+ from captain_hook import Allow, Block, Input, block_command
56
+
57
+ block_command(
58
+ ["git", "stash"],
59
+ reason="Use the team's VCS workflow for shelving changes",
60
+ hint="Commit a WIP change instead of stashing",
61
+ tests={
62
+ Input(command="git stash"): Block(),
63
+ Input(command="git status"): Allow(),
64
+ },
65
+ )
66
+ ```
67
+
68
+ Run them from your project root, where `--hooks` defaults to `.claude/hooks`:
69
+
70
+ ```bash
71
+ uvx capt-hook test
72
+ ```
73
+
74
+ Wire that into CI and you catch a broken hook the way you catch broken code.
75
+
76
+ ## What it's for
77
+
78
+ - Block footguns before they run on `PreToolUse`: force-push, `rm -rf`, package-manager traps.
79
+ - Steer the agent with feedback that fires on the patterns it actually emits: repeated failures, weakened tests, missed conventions.
80
+ - Hold the line on multi-step work with Stop gates and artifact checks, so the agent can't call it "done" before the tests run or the report's written.
81
+ - Keep all of it testable; every hook ships with inline tests that run in CI.
82
+
83
+ ## Docs
84
+
85
+ [Read the docs](https://yasyf.github.io/captain-hook/) for the full guide to conditions, primitives, LLM hooks, workflows, state, and real-world patterns. To work on captain-hook itself, see the [development guide](https://yasyf.github.io/captain-hook/docs/development/).
86
+
87
+ ## License
88
+
89
+ Licensed under [PolyForm Noncommercial 1.0.0](LICENSE), free for noncommercial use.
@@ -0,0 +1,95 @@
1
+ from __future__ import annotations
2
+
3
+ from cc_transcript.tools import (
4
+ BashCall,
5
+ EditCall,
6
+ ExitPlanModeCall,
7
+ GlobCall,
8
+ GrepCall,
9
+ MultiEditCall,
10
+ NotebookEditCall,
11
+ OtherCall,
12
+ ReadCall,
13
+ SkillCall,
14
+ TaskCall,
15
+ TaskCreateCall,
16
+ TaskUpdateCall,
17
+ ToolCall,
18
+ ToolCallBase,
19
+ WriteCall,
20
+ )
21
+
22
+ from captain_hook import file, style, utils
23
+ from captain_hook.app import hook, on, register
24
+ from captain_hook.command import Command, CommandLine, Redirect
25
+ from captain_hook.context import HookContext
26
+ from captain_hook.events import (
27
+ BaseHookEvent,
28
+ NotificationEvent,
29
+ PostToolUseEvent,
30
+ PostToolUseFailureEvent,
31
+ PreCompactEvent,
32
+ PreToolUseEvent,
33
+ SessionEndEvent,
34
+ StopEvent,
35
+ SubagentStartEvent,
36
+ SubagentStopEvent,
37
+ ToolHookEvent,
38
+ UserPromptSubmitEvent,
39
+ )
40
+ from captain_hook.file import File, categorize_files
41
+ from captain_hook.primitives import (
42
+ GateVerdict,
43
+ NudgeVerdict,
44
+ PromptCheckVerdict,
45
+ block_command,
46
+ gate,
47
+ llm_evaluate,
48
+ llm_gate,
49
+ llm_nudge,
50
+ prompt_check,
51
+ rewrite_code,
52
+ rewrite_command,
53
+ warn_command,
54
+ )
55
+
56
+ # lint/nudge are imported from their defining modules, not the primitives
57
+ # package: the package attribute and the submodule share a name, and an alias
58
+ # targeting captain_hook.primitives.<name> resolves to the module under static
59
+ # analysis (griffe), shadowing the function.
60
+ from captain_hook.primitives.lint import diff_lint, lint
61
+ from captain_hook.primitives.nudge import nudge
62
+ from captain_hook.primitives.workflow import Artifact, Step, Workflow, text_matches, workflow
63
+ from captain_hook.prompt import Prompt
64
+ from captain_hook.session import SessionSlot, SessionStore, session_state
65
+ from captain_hook.settings import HooksSettings, build_settings
66
+ from captain_hook.signals.nlp import Clause, NlpSignal, Phrase
67
+ from captain_hook.state import HookState, PrimitiveState, WorkflowState, workflow_state
68
+ from captain_hook.tasks import Task, Tasks
69
+ from captain_hook.testing import Allow, Block, FileFixture, InlineTests, Input, Rewrite, TranscriptFixture, Warn
70
+ from captain_hook.types import (
71
+ Action,
72
+ Agent,
73
+ Content,
74
+ CustomCommandLineCondition,
75
+ CustomCondition,
76
+ CustomInputTypeCondition,
77
+ Event,
78
+ FilePath,
79
+ HookResponse,
80
+ HookResult,
81
+ InPlanMode,
82
+ Pattern,
83
+ RanCommand,
84
+ ReadFile,
85
+ Signal,
86
+ Signals,
87
+ SourceEdits,
88
+ TCondition,
89
+ TestFile,
90
+ Tool,
91
+ TouchedFile,
92
+ UsedSkill,
93
+ Waiting,
94
+ )
95
+ from captain_hook.utils import read_json, resolve_binary
@@ -7,8 +7,6 @@ from fnmatch import fnmatch
7
7
  from pathlib import Path
8
8
  from typing import TYPE_CHECKING, get_args
9
9
 
10
- from pydantic_settings import BaseSettings
11
-
12
10
  from captain_hook.conditions import matches_conditions
13
11
  from captain_hook.types import (
14
12
  CustomCondition,
@@ -23,6 +21,7 @@ if TYPE_CHECKING:
23
21
  from cc_transcript.activity import UserClassifier
24
22
 
25
23
  from captain_hook.events import BaseHookEvent
24
+ from captain_hook.settings import HooksSettings
26
25
  from captain_hook.types import HookResult
27
26
 
28
27
  HookHandler = Callable[["BaseHookEvent"], "HookResult | None"]
@@ -69,7 +68,7 @@ def validate_handler_signature(fn: HookHandler) -> None:
69
68
  class State:
70
69
  hooks: list[RegisteredHook] = field(default_factory=list)
71
70
  gitignore_patterns: list[str] = field(default_factory=list)
72
- settings: BaseSettings | None = None
71
+ settings: HooksSettings | None = None
73
72
  classifier: UserClassifier | None = None
74
73
  counter: int = field(default=0, repr=False)
75
74
 
@@ -254,7 +253,7 @@ def is_planning_agent_skip(spec: HookSpec, evt: BaseHookEvent) -> bool:
254
253
  return False
255
254
  if evt.event not in (Event.SubagentStop | Event.SubagentStart):
256
255
  return False
257
- names = getattr(_state.settings, "planning_agents", DEFAULT_PLANNING_AGENTS)
256
+ names = settings.planning_agents if (settings := _state.settings) else DEFAULT_PLANNING_AGENTS
258
257
  return bool(evt.agent_type and evt.agent_type in names)
259
258
 
260
259
 
@@ -0,0 +1,136 @@
1
+ """The only module that imports ``ast_grep_py``: structural code search and rewriting by language.
2
+
3
+ Everything else in the framework reaches ast-grep through here — the :class:`~captain_hook.types.Pattern`
4
+ condition, the ``ast_grep_rule`` style builders, and the ``rewrite_code`` primitive — so the binding
5
+ lives behind one seam.
6
+
7
+ Patterns are plain ast-grep pattern strings with metavariables: ``print($$$)`` matches a print call
8
+ however its arguments are spelled, ``os.system($CMD)`` captures the argument as ``$CMD``. Rewrites
9
+ reuse ast-grep's ``$VAR`` / ``$$$VAR`` fix syntax. Language ids are the same short keys as
10
+ :data:`~captain_hook.types.LANG_GLOBS` (``"py"``, ``"go"``, ``"ts"``, ...); ast-grep also accepts the
11
+ long names (``"python"``).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from collections.abc import Iterator
18
+ from dataclasses import dataclass
19
+ from typing import TYPE_CHECKING
20
+
21
+ from captain_hook.types import LANG_GLOBS
22
+
23
+ if TYPE_CHECKING:
24
+ from pathlib import Path
25
+
26
+ from ast_grep_py import SgNode
27
+
28
+ EXT_TO_LANG: dict[str, str] = {glob.removeprefix("*."): lang for lang, globs in LANG_GLOBS.items() for glob in globs}
29
+
30
+ TEMPLATE_VAR = re.compile(r"\$\$\$([A-Z_][A-Z0-9_]*)|\$([A-Z_][A-Z0-9_]*)")
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class Match:
35
+ """A structural match, located by 1-based line to align with ``Violation`` and changed-line scoping."""
36
+
37
+ line: int
38
+ end_line: int
39
+ text: str
40
+
41
+
42
+ def lang_for_path(path: Path) -> str | None:
43
+ """Infer an ast-grep language id from a file extension, or ``None`` when unsupported."""
44
+ return EXT_TO_LANG.get(path.suffix.removeprefix("."))
45
+
46
+
47
+ def has_metavar(text: str) -> bool:
48
+ """Whether ``text`` carries an ast-grep metavariable (``$NAME`` or ``$$$NAME``)."""
49
+ return TEMPLATE_VAR.search(text) is not None
50
+
51
+
52
+ def parse(source: str, lang: str) -> SgNode:
53
+ from ast_grep_py import SgRoot
54
+
55
+ return SgRoot(source, lang).root()
56
+
57
+
58
+ def to_match(node: SgNode) -> Match:
59
+ r = node.range()
60
+ return Match(line=r.start.line + 1, end_line=r.end.line + 1, text=node.text())
61
+
62
+
63
+ def match_key(m: Match) -> str:
64
+ return " ".join(m.text.split())
65
+
66
+
67
+ def matches(source: str, lang: str, pattern: str) -> bool:
68
+ """Whether ``pattern`` matches anywhere in ``source`` — the cheap boolean for conditions."""
69
+ return parse(source, lang).find(pattern=pattern) is not None
70
+
71
+
72
+ def find_all(source: str, lang: str, pattern: str) -> Iterator[Match]:
73
+ """Every structural match of ``pattern`` in ``source``, as 1-based-line :class:`Match` objects."""
74
+ return (to_match(node) for node in parse(source, lang).find_all(pattern=pattern))
75
+
76
+
77
+ def find_introduced(old: str, new: str, lang: str, pattern: str) -> Iterator[Match]:
78
+ """Matches present in ``new`` whose construct was absent from ``old`` — the diff helper.
79
+
80
+ Identity is the match's whitespace-normalized text, not its range (which shifts as
81
+ surrounding code moves) — so a pre-existing construct is never reported as newly added.
82
+ """
83
+ before = {match_key(m) for m in find_all(old, lang, pattern)}
84
+ return (m for m in find_all(new, lang, pattern) if match_key(m) not in before)
85
+
86
+
87
+ def rewrite(source: str, lang: str, pattern: str, replace: str) -> str:
88
+ """Rewrite every ``pattern`` match in ``source`` to ``replace``, an ast-grep fix template.
89
+
90
+ ``replace`` uses ast-grep's ``$VAR`` / ``$$$VAR`` fix syntax, each metavariable filled from the
91
+ match it names. Returns ``source`` unchanged when nothing matches.
92
+ """
93
+ root = parse(source, lang)
94
+ if not (edits := [node.replace(fill_template(node, replace)) for node in root.find_all(pattern=pattern)]):
95
+ return source
96
+ return root.commit_edits(edits)
97
+
98
+
99
+ def capture(source: str, lang: str, pattern: str) -> dict[str, str] | None:
100
+ """Match ``pattern`` against ``source`` and extract its named metavars, or ``None`` when it doesn't match.
101
+
102
+ Each ``$NAME`` in the pattern maps to the matched node's text; each ``$$$NAME`` maps to the
103
+ original-source span covering its matches, so whitespace is preserved (mirroring :func:`fill_template`).
104
+ A pattern with no metavars that still matches yields an empty dict — present but empty.
105
+ """
106
+ if (node := parse(source, lang).find(pattern=pattern)) is None:
107
+ return None
108
+
109
+ def value(m: re.Match[str]) -> str:
110
+ if (name := m.group(1)) is not None:
111
+ if not (spans := node.get_multiple_matches(name)):
112
+ return ""
113
+ full = node.get_root().root().text()
114
+ return full[spans[0].range().start.index : spans[-1].range().end.index]
115
+ return single.text() if (single := node.get_match(m.group(2))) else ""
116
+
117
+ return {(m.group(1) or m.group(2)): value(m) for m in TEMPLATE_VAR.finditer(pattern)}
118
+
119
+
120
+ def fill_template(node: SgNode, template: str) -> str:
121
+ """Fill an ast-grep fix ``template`` against one match: ``$NAME`` becomes the metavar's text;
122
+ ``$$$NAME`` becomes the original-source span covering its matches, so whitespace is preserved.
123
+
124
+ A ``$NAME`` the pattern never captured is left untouched, so literal ``$VAR`` text in a
125
+ replacement — a shell variable like ``$HOME``, say — passes through unchanged.
126
+ """
127
+
128
+ def substitute(m: re.Match[str]) -> str:
129
+ if (name := m.group(1)) is not None:
130
+ if not (spans := node.get_multiple_matches(name)):
131
+ return ""
132
+ source = node.get_root().root().text()
133
+ return source[spans[0].range().start.index : spans[-1].range().end.index]
134
+ return single.text() if (single := node.get_match(m.group(2))) else m.group(0)
135
+
136
+ return TEMPLATE_VAR.sub(substitute, template)