capt-hook 3.2.0__tar.gz → 3.3.1__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.2.0 → capt_hook-3.3.1}/PKG-INFO +21 -15
  2. {capt_hook-3.2.0 → capt_hook-3.3.1}/README.md +20 -14
  3. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/cli.py +59 -39
  4. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/packs/manager.py +28 -5
  5. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/review/cli.py +19 -10
  6. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/review/settings.py +1 -1
  7. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/skills/bootstrapping-hooks/SKILL.md +28 -19
  8. {capt_hook-3.2.0 → capt_hook-3.3.1}/pyproject.toml +1 -1
  9. {capt_hook-3.2.0 → capt_hook-3.3.1}/LICENSE +0 -0
  10. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/.claude-plugin/plugin.json +0 -0
  11. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/__init__.py +0 -0
  12. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/__main__.py +0 -0
  13. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/app.py +0 -0
  14. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/classifiers/__init__.py +0 -0
  15. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/classifiers/conductor.py +0 -0
  16. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/classifiers/droid.py +0 -0
  17. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/classifiers/native.py +0 -0
  18. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/command.py +0 -0
  19. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/conditions.py +0 -0
  20. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/context.py +0 -0
  21. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/decisions.py +0 -0
  22. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/dispatch.py +0 -0
  23. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/events.py +0 -0
  24. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/file.py +0 -0
  25. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/llm/__init__.py +0 -0
  26. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/loader.py +0 -0
  27. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/log.py +0 -0
  28. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/packs/__init__.py +0 -0
  29. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/packs/general/capt-hook.toml +0 -0
  30. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/packs/general/commands.py +0 -0
  31. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/packs/general/docs.py +0 -0
  32. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/packs/general/plans.py +0 -0
  33. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/packs/general/prompts.py +0 -0
  34. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/packs/general/review.py +0 -0
  35. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/packs/general/stewardship.py +0 -0
  36. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/packs/general/tasks.py +0 -0
  37. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/packs/python/capt-hook.toml +0 -0
  38. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/packs/python/style.py +0 -0
  39. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/packs/python/testing.py +0 -0
  40. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/packs/python/toolchain.py +0 -0
  41. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/primitives/__init__.py +0 -0
  42. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/primitives/commands.py +0 -0
  43. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/primitives/lint.py +0 -0
  44. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/primitives/llm.py +0 -0
  45. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/primitives/nudge.py +0 -0
  46. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/primitives/workflow.py +0 -0
  47. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/prompt.py +0 -0
  48. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/py.typed +0 -0
  49. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/review/__init__.py +0 -0
  50. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/review/fix.py +0 -0
  51. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/review/formats.py +0 -0
  52. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/review/judge.py +0 -0
  53. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/review/pipeline.py +0 -0
  54. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/review/repo.py +0 -0
  55. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/review/scan.py +0 -0
  56. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/review/store.py +0 -0
  57. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/review/sync.py +0 -0
  58. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/session.py +0 -0
  59. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/settings.py +0 -0
  60. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/signals/__init__.py +0 -0
  61. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/signals/nlp.py +0 -0
  62. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/skills/authoring-hooks/SKILL.md +0 -0
  63. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +0 -0
  64. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +0 -0
  65. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
  66. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +0 -0
  67. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/skills/scanning-sessions/SKILL.md +0 -0
  68. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
  69. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
  70. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
  71. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
  72. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
  73. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
  74. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/state.py +0 -0
  75. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/style/__init__.py +0 -0
  76. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/style/matchers.py +0 -0
  77. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/style/scope.py +0 -0
  78. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/style/types.py +0 -0
  79. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/tasks.py +0 -0
  80. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/templates/example_hook.py.tmpl +0 -0
  81. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/testing/__init__.py +0 -0
  82. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/testing/helpers.py +0 -0
  83. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/testing/session_cache.py +0 -0
  84. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/testing/types.py +0 -0
  85. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/tests/__init__.py +0 -0
  86. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/tests/helpers.py +0 -0
  87. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/types.py +0 -0
  88. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/util/__init__.py +0 -0
  89. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/util/model_cache.py +0 -0
  90. {capt_hook-3.2.0 → capt_hook-3.3.1}/captain_hook/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: capt-hook
3
- Version: 3.2.0
3
+ Version: 3.3.1
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
 
@@ -157,20 +157,33 @@ def is_captain_hook_group(group: dict[str, Any]) -> bool:
157
157
  return any("capt-hook" in (h.get("command") or "") for h in group.get("hooks") or [])
158
158
 
159
159
 
160
+ def capt_hook_events(path: Path) -> set[str]:
161
+ if not path.exists():
162
+ return set()
163
+ return {
164
+ event
165
+ for event, groups in (json.loads(path.read_text()).get("hooks") or {}).items()
166
+ if any(is_captain_hook_group(g) for g in groups)
167
+ }
168
+
169
+
160
170
  def merge_settings(
161
171
  hooks_dir: str, settings_path: Path, from_source: str = DIST_NAME
162
172
  ) -> tuple[dict[str, Any], dict[str, str]]:
163
173
  new_hooks: dict[str, list[dict[str, Any]]] = generate_settings(hooks_dir, from_source=from_source)["hooks"]
164
174
  existing = json.loads(settings_path.read_text()) if settings_path.exists() else {}
165
175
  existing_hooks: dict[str, list[dict[str, Any]]] = existing.get("hooks") or {}
176
+ committed = capt_hook_events(settings_path.parent / "settings.json")
166
177
 
167
178
  summary: dict[str, str] = {}
168
179
  merged_hooks: dict[str, list[dict[str, Any]]] = {}
169
180
  for event in sorted(existing_hooks.keys() | new_hooks.keys()):
170
181
  foreign = [g for g in existing_hooks.get(event, []) if not is_captain_hook_group(g)]
171
182
  old_own = [g for g in existing_hooks.get(event, []) if is_captain_hook_group(g)]
172
- fresh_own = new_hooks.get(event, [])
173
- if old_own or fresh_own:
183
+ fresh_own = [] if event in committed else new_hooks.get(event, [])
184
+ if event in committed and (old_own or new_hooks.get(event)):
185
+ summary[event] = "deferred"
186
+ elif old_own or fresh_own:
174
187
  summary[event] = (
175
188
  "unchanged"
176
189
  if old_own == fresh_own
@@ -196,17 +209,19 @@ def print_hook_summary(label: str, summary: dict[str, str]) -> None:
196
209
  by_status: defaultdict[str, list[str]] = defaultdict(list)
197
210
  for event, status in summary.items():
198
211
  by_status[status].append(event)
199
- print(f"{label}:")
212
+ click.echo(f"{label}:")
200
213
  if not summary:
201
- print(" no hook entries")
214
+ click.echo(" no hook entries")
202
215
  for event in by_status["added"]:
203
- print(f" + added {event}")
216
+ click.echo(f" + added {event}")
204
217
  for event in by_status["updated"]:
205
- print(f" ~ updated {event}")
218
+ click.echo(f" ~ updated {event}")
206
219
  for event in by_status["removed"]:
207
- print(f" - removed {event}")
220
+ click.echo(f" - removed {event}")
208
221
  if unchanged := by_status["unchanged"]:
209
- print(f" unchanged: {', '.join(unchanged)} (already present)")
222
+ click.echo(f" unchanged: {', '.join(unchanged)} (already present)")
223
+ if deferred := by_status["deferred"]:
224
+ click.echo(f" deferred to settings.json: {', '.join(deferred)}")
210
225
 
211
226
 
212
227
  def regenerate_settings(state: CliState) -> None:
@@ -218,16 +233,8 @@ def regenerate_settings(state: CliState) -> None:
218
233
 
219
234
 
220
235
  def settings_drift(root: Path) -> set[str]:
221
- settings = [p for name in ("settings.json", "settings.local.json") if (p := root / ".claude" / name).exists()]
222
- if not settings:
223
- return set()
224
- wired = {
225
- event
226
- for path in settings
227
- for event, groups in (json.loads(path.read_text()).get("hooks") or {}).items()
228
- if any(is_captain_hook_group(g) for g in groups)
229
- }
230
- return subscribed_events() - wired
236
+ paths = [p for name in ("settings.json", "settings.local.json") if (p := root / ".claude" / name).exists()]
237
+ return subscribed_events() - {event for p in paths for event in capt_hook_events(p)} if paths else set()
231
238
 
232
239
 
233
240
  def warn_settings_drift(
@@ -303,13 +310,15 @@ def run_event(
303
310
  print(json.dumps(output))
304
311
 
305
312
 
306
- def init_project(root: Path) -> None:
313
+ def init_project(root: Path, *, review: bool = True) -> None:
314
+ from captain_hook.review.cli import watch_repo
315
+ from captain_hook.review.repo import repo_key
316
+
307
317
  hooks_dir = root / ".claude" / "hooks"
308
318
  hooks_dir.mkdir(parents=True, exist_ok=True)
309
319
 
310
320
  example = hooks_dir / "example.py"
311
- example_created = not example.exists()
312
- if example_created:
321
+ if not example.exists():
313
322
  example.write_text(example_hook_source())
314
323
 
315
324
  settings_path = root / ".claude" / "settings.local.json"
@@ -319,23 +328,33 @@ def init_project(root: Path) -> None:
319
328
 
320
329
  skills_summary = install_skills(root)
321
330
 
322
- print(f"Scaffolded {example.relative_to(root)} + {settings_path.relative_to(root)}.")
323
- print()
331
+ click.echo(f"Scaffolded {example.relative_to(root)} + {settings_path.relative_to(root)}.")
332
+ click.echo()
324
333
  print_hook_summary(str(settings_path.relative_to(root)), summary)
325
- print()
326
- print(".claude/skills/:")
334
+ click.echo()
335
+ click.echo(".claude/skills/:")
327
336
  for name in (n for n, status in skills_summary.items() if status == "installed"):
328
- print(f" + installed {name}")
337
+ click.echo(f" + installed {name}")
329
338
  if skipped := [n for n, status in skills_summary.items() if status == "skipped"]:
330
- print(f" unchanged: {', '.join(skipped)} (already present; capt-hook skills install --force to refresh)")
331
- print()
332
- print("Next:")
333
- print(" 1. Read the quickstart: https://yasyf.github.io/captain-hook/")
334
- print(" 2. Edit example.py or add new files under .claude/hooks/")
335
- print(" 3. uvx capt-hook test # verify inline tests")
336
- print(" 4. uvx capt-hook register-hooks # re-register after adding events")
337
- print(" 5. /bootstrapping-hooks in Claude # mine hooks from this repo's conventions")
338
- print()
339
+ click.echo(f" unchanged: {', '.join(skipped)} (already present; capt-hook skills install --force to refresh)")
340
+ click.echo()
341
+ match (review, repo_key(root)):
342
+ case (False, _):
343
+ click.echo("Session reviewer: skipped (--no-review) `uvx capt-hook review enable` to turn it on later.")
344
+ case (True, None):
345
+ click.echo("Session reviewer: needs a git repo with a remote — `uvx capt-hook review enable` once it has one.")
346
+ case (True, repo):
347
+ watch_repo(repo)
348
+ click.echo(f"Session reviewer: watching {repo} — mines your ended sessions and opens hook PRs automatically.")
349
+ click.echo(" Stop anytime with `uvx capt-hook review disable`.")
350
+ click.echo()
351
+ click.echo("Next:")
352
+ click.echo(" 1. Read the quickstart: https://yasyf.github.io/captain-hook/")
353
+ click.echo(" 2. Edit example.py or add new files under .claude/hooks/")
354
+ click.echo(" 3. uvx capt-hook test # verify inline tests")
355
+ click.echo(" 4. uvx capt-hook register-hooks # re-register after adding events")
356
+ click.echo(' 5. Ask Claude "set up captain hook" # mine guardrails from this repo (the bootstrapping-hooks skill)')
357
+ click.echo()
339
358
  maybe_launch_bootstrap(root)
340
359
 
341
360
 
@@ -493,10 +512,11 @@ def test(state: CliState, json_output: bool) -> None:
493
512
 
494
513
 
495
514
  @cli.command()
515
+ @click.option("--no-review", is_flag=True, default=False, help="Skip enabling the SessionEnd session reviewer for this repo")
496
516
  @click.pass_obj
497
- def init(state: CliState) -> None:
498
- """Scaffold the hooks directory, install bundled skills, and wire settings."""
499
- init_project(state.root)
517
+ def init(state: CliState, no_review: bool) -> None:
518
+ """Scaffold the hooks directory, install bundled skills, wire settings, and enable the session reviewer."""
519
+ init_project(state.root, review=not no_review)
500
520
 
501
521
 
502
522
  @cli.command()
@@ -537,9 +557,9 @@ def pack_add(state: CliState, target: str) -> None:
537
557
  if target in manager.builtin_packs()
538
558
  else manager.fetch_pack(manager.PackSource.parse(target)).entry
539
559
  )
540
- manager.upsert_entry(manager.packs_toml_path(state.root), entry)
541
560
  except manager.PackError as e:
542
561
  raise click.ClickException(str(e)) from e
562
+ manager.upsert_entry(manager.packs_toml_path(state.root), entry)
543
563
  click.echo(f" added {entry.name}")
544
564
  regenerate_settings(state)
545
565
 
@@ -170,11 +170,25 @@ def resolve_commit(source: PackSource) -> str:
170
170
 
171
171
  def strip_top_level(tf: tarfile.TarFile) -> Iterator[tarfile.TarInfo]:
172
172
  for member in tf.getmembers():
173
- if (tail := member.name.partition("/")[2]):
173
+ if tail := member.name.partition("/")[2]:
174
174
  member.path = tail
175
175
  yield member
176
176
 
177
177
 
178
+ def members_under(members: list[tarfile.TarInfo], hooks: str) -> Iterator[tarfile.TarInfo]:
179
+ """Yield the manifest plus members within the pack's hooks dir.
180
+
181
+ hooks == "." (hooks beside the manifest) selects the whole tree; a real
182
+ subdir selects only the manifest and that subtree, so the cache holds just
183
+ what the loader imports.
184
+ """
185
+ rel = hooks.strip("/")
186
+ prefix = "" if rel in ("", ".") else rel + "/"
187
+ for m in members:
188
+ if m.path == PACK_MANIFEST or not prefix or m.path == rel or m.path.startswith(prefix):
189
+ yield m
190
+
191
+
178
192
  def find_cached(name: str, sha: str) -> Path | None:
179
193
  return d if (d := packs_cache_root() / f"{name}@{sha}").is_dir() and (d / SHA_MARKER).is_file() else None
180
194
 
@@ -188,14 +202,21 @@ def fetch_commit(source: PackSource, sha: str) -> ResolvedPack:
188
202
  if staging.exists():
189
203
  shutil.rmtree(staging)
190
204
  with tarfile.open(tarball) as tf:
191
- tf.extractall(staging, members=strip_top_level(tf), filter="data")
192
- manifest = PackManifest.load(staging / PACK_MANIFEST)
205
+ members = list(strip_top_level(tf))
206
+ manifest_member = next((m for m in members if m.path == PACK_MANIFEST), None)
207
+ if manifest_member is None:
208
+ raise PackError(f"pack manifest {PACK_MANIFEST} missing in {source}")
209
+ tf.extract(manifest_member, staging, filter="data")
210
+ manifest = PackManifest.load(staging / PACK_MANIFEST)
211
+ tf.extractall(staging, members=list(members_under(members, manifest.hooks)), filter="data")
193
212
  final = root / f"{manifest.name}@{sha}"
194
213
  if final.exists():
195
214
  shutil.rmtree(final)
196
215
  os.replace(staging, final)
197
216
  (final / SHA_MARKER).write_text(sha)
198
- return ResolvedPack(ExternalPack(name=manifest.name, source=source, commit=sha), manifest.hooks_dir(final), manifest)
217
+ return ResolvedPack(
218
+ ExternalPack(name=manifest.name, source=source, commit=sha), manifest.hooks_dir(final), manifest
219
+ )
199
220
 
200
221
 
201
222
  def fetch_pack(source: PackSource) -> ResolvedPack:
@@ -228,6 +249,8 @@ def resolve_enabled_packs(root: Path) -> tuple[list[ResolvedPack], list[str]]:
228
249
  match entry:
229
250
  case BuiltinPack(name=name):
230
251
  resolved.append(resolve_builtin(name))
252
+ case ExternalPack() as ext if found := resolve_external(ext):
253
+ resolved.append(found)
231
254
  case ExternalPack() as ext:
232
- resolved.append(found) if (found := resolve_external(ext)) else missing.append(ext.name)
255
+ missing.append(ext.name)
233
256
  return resolved, missing
@@ -60,8 +60,10 @@ def ensure_review_wiring(settings_path: Path) -> bool:
60
60
  from captain_hook.cli import write_settings
61
61
 
62
62
  existing: dict[str, Any] = json.loads(settings_path.read_text()) if settings_path.exists() else {}
63
+ committed = settings_path.parent / "settings.json"
64
+ committed_hooks: dict[str, Any] = (json.loads(committed.read_text()).get("hooks") or {}) if committed.exists() else {}
63
65
  hooks: dict[str, Any] = existing.get("hooks") or {}
64
- if review_wired(hooks):
66
+ if review_wired(hooks) or review_wired(committed_hooks):
65
67
  return False
66
68
  group = {"hooks": [{"type": "command", "command": f"uvx {REVIEW_RUN_COMMAND}"}]}
67
69
  write_settings(
@@ -71,6 +73,18 @@ def ensure_review_wiring(settings_path: Path) -> bool:
71
73
  return True
72
74
 
73
75
 
76
+ def watch_repo(repo: RepoKey) -> None:
77
+ """Flip the global watching bit for ``repo`` — the single persistence path shared by ``init`` and ``review enable``."""
78
+ from captain_hook.review.settings import ReviewSettings
79
+ from captain_hook.review.store import ReviewStore
80
+
81
+ async def go() -> None:
82
+ async with await ReviewStore.open(ReviewSettings().db_path) as store:
83
+ await store.enable(repo)
84
+
85
+ asyncio.run(go())
86
+
87
+
74
88
  def candidate_line(row: dict[str, object]) -> str:
75
89
  text = str(row["sample_text"] or "").replace("\n", " ")
76
90
  return (
@@ -106,17 +120,12 @@ def spawn(transcript: Path, cwd: str | None) -> None:
106
120
  @review.command()
107
121
  @click.pass_obj
108
122
  def enable(state: CliState) -> None:
109
- """Watch the current repo and wire the SessionEnd reviewer hook."""
110
- from captain_hook.review.settings import ReviewSettings
111
- from captain_hook.review.store import ReviewStore
123
+ """Watch the current repo, install the reviewer's skills, and wire the SessionEnd hook."""
124
+ from captain_hook.cli import install_skills
112
125
 
113
126
  repo = current_repo(state.root)
114
-
115
- async def go() -> None:
116
- async with await ReviewStore.open(ReviewSettings().db_path) as store:
117
- await store.enable(repo)
118
-
119
- asyncio.run(go())
127
+ watch_repo(repo)
128
+ install_skills(state.root)
120
129
  wired = ensure_review_wiring(state.root / ".claude" / "settings.local.json")
121
130
  click.echo(f"watching {repo}" + (" (SessionEnd hook wired into .claude/settings.local.json)" if wired else ""))
122
131
 
@@ -36,7 +36,7 @@ class ReviewSettings(HooksSettings):
36
36
  min_days_fix: int = 0
37
37
  min_confidence_fix: Confidence = MEDIUM
38
38
  min_confidence_fix_single: Confidence = VERY_HIGH
39
- judge_tier: TModel = "small"
39
+ judge_tier: TModel = "medium"
40
40
  judge_concurrency: int = 8
41
41
  judge_timeout: int = 180
42
42
  min_judge_confidence: float = 0.6
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: bootstrapping-hooks
3
- description: Surveys a repository and sets up captain-hook (capt-hook) guardrails for Claude Code — blocking gates, advisory nudges, command blocks, and test-integrity checks mined from the repo's own docs, CI workflows, lint configs, and git history. Proposes categorized candidates for user approval before writing anything, then writes .claude/hooks/*.py with inline tests, verifies with capt-hook test, and wires .claude/settings.local.json. Use when the user asks to "set up hooks", "bootstrap capt-hook", "add guardrails", "enforce our conventions with hooks", "protect this repo", or "make Claude follow CONTRIBUTING.md".
3
+ description: Surveys a repository and sets up captain-hook (capt-hook) guardrails for Claude Code — blocking gates, advisory nudges, command blocks, and test-integrity checks mined from the repo's own docs, CI workflows, lint configs, and git history. Scaffolds the framework and enables the session reviewer up front (Step 1), then proposes categorized candidates for user approval before writing anything, then writes .claude/hooks/*.py with inline tests, verifies with capt-hook test, and wires .claude/settings.local.json. Use when the user asks to "set up captain hook", "set up capt-hook", "set up hooks", "bootstrap capt-hook", "add guardrails", "enforce our conventions with hooks", "protect this repo", or "make Claude follow CONTRIBUTING.md".
4
4
  argument-hint: "[repo path] (defaults to current project)"
5
5
  allowed-tools: Read, Grep, Glob, AskUserQuestion, Write, Edit, Bash(uvx capt-hook:*, capt-hook:*, git log:*, git diff:*, ls:*, find:*)
6
6
  ---
@@ -29,28 +29,41 @@ Copy this checklist into your response and check off steps as you complete them:
29
29
 
30
30
  ```
31
31
  Bootstrap Progress:
32
- - [ ] Step 1: Locate + pre-flight (.claude/hooks, settings)
32
+ - [ ] Step 1: Locate + scaffold (init or review enable) + pre-flight
33
33
  - [ ] Step 2: Survey the repo (docs, CI, lint configs, git log)
34
34
  - [ ] Step 3: Mine candidates onto the taxonomy
35
35
  - [ ] Step 4: Propose via AskUserQuestion — nothing written before approval
36
- - [ ] Step 5: Scaffold (uvx capt-hook init)
36
+ - [ ] Step 5: Clear the demo example.py (scaffolding ran in Step 1)
37
37
  - [ ] Step 6: Draft approved hooks via authoring-hooks, one file per category
38
38
  - [ ] Step 7: Verify (uvx capt-hook test, fix until green)
39
39
  - [ ] Step 8: Wire settings (register-hooks if new events)
40
40
  - [ ] Step 9: Final report (table + declined list)
41
41
  ```
42
42
 
43
- ### 1. Locate + pre-flight
43
+ ### 1. Locate + scaffold + pre-flight
44
44
 
45
- Resolve the target repo (argument path, else current project). Run:
45
+ Resolve the target repo (argument path, else current project). Inspect what's already wired:
46
46
 
47
47
  ```bash
48
48
  ls .claude/hooks/ 2>/dev/null
49
+ grep -lq 'capt-hook' .claude/settings.json 2>/dev/null && echo COMMITTED || echo FRESH
49
50
  ```
50
51
 
51
- Read `.claude/settings.local.json` and `.claude/settings.json` if present. If capt-hook hooks
52
- already exist, switch to **additive mode**: never overwrite existing hook files; new categories
53
- go in new files, and the Step 4 menu only offers candidates not already covered.
52
+ Then scaffold up front, so the framework and the session reviewer are live before you propose
53
+ anything pick the command by what you found:
54
+
55
+ - **FRESH** (no committed capt-hook wiring) — run `uvx capt-hook init`. It scaffolds
56
+ `.claude/hooks/`, wires `.claude/settings.local.json`, installs the skills, and **enables the
57
+ session reviewer** (watching this repo; it mines ended sessions and opens hook PRs —
58
+ `uvx capt-hook review disable` to stop).
59
+ - **COMMITTED** (a checked-in `.claude/settings.json` already runs `uvx capt-hook run …`) — do
60
+ **not** run `init`; it would duplicate those hooks into `settings.local.json` and double-fire.
61
+ Run `uvx capt-hook review enable` instead — it installs the reviewer skills and arms the session
62
+ reviewer without touching the committed event hooks.
63
+
64
+ Read `.claude/settings.local.json` and `.claude/settings.json`. If capt-hook hooks already exist,
65
+ switch to **additive mode**: never overwrite existing hook files; new categories go in new files,
66
+ and the Step 4 menu only offers candidates not already covered.
54
67
 
55
68
  ### 2. Survey the repo
56
69
 
@@ -99,17 +112,11 @@ and the proposed primitive + severity. Then one final severity question:
99
112
  If a styleguide-like markdown was found, category E is a single option: **"Translate `<file>`
100
113
  into enforced style rules (runs the translating-styleguides skill)"**.
101
114
 
102
- ### 5. Scaffold
103
-
104
- Run:
105
-
106
- ```bash
107
- uvx capt-hook init
108
- ```
115
+ ### 5. Clear the demo example.py
109
116
 
110
- This creates `.claude/hooks/example.py` (only when absent) and merges capt-hook entries into
111
- `.claude/settings.local.json`. If this run created the demo `example.py`, delete it after
112
- writing the real hooks the approved hooks replace it.
117
+ Scaffolding already ran in Step 1. If that `init` created the demo `.claude/hooks/example.py`,
118
+ delete it once you've drafted the real hooks (Step 6) — the approved hooks replace it. (In
119
+ **COMMITTED** repos `review enable` writes no `example.py`, so there's nothing to clear.)
113
120
 
114
121
  ### 6. Write hooks
115
122
 
@@ -168,7 +175,9 @@ Declined: <candidates the user rejected, with their source quotes>
168
175
  ```
169
176
 
170
177
  Close with next steps: `uvx capt-hook logs --tail 50` to inspect live firings, and tune
171
- `max_fires` on any hook that nags.
178
+ `max_fires` on any hook that nags. Note that Step 1 also armed the **session reviewer** — it now
179
+ watches this repo, mines your ended sessions for durable corrections, and opens hook PRs
180
+ automatically; `uvx capt-hook review disable` turns it off.
172
181
 
173
182
  ## Worked mini-example
174
183
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "capt-hook"
3
- version = "3.2.0"
3
+ version = "3.3.1"
4
4
  description = "Declarative hook framework for Claude Code"
5
5
  readme = "README.md"
6
6
  license = "PolyForm-Noncommercial-1.0.0"
File without changes
File without changes
File without changes