capt-hook 6.3.0__tar.gz → 6.5.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 (111) hide show
  1. capt_hook-6.5.0/PKG-INFO +146 -0
  2. capt_hook-6.5.0/README.md +98 -0
  3. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/cli.py +3 -1
  4. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/context.py +4 -3
  5. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/durable.py +1 -8
  6. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/loader.py +5 -6
  7. capt_hook-6.5.0/captain_hook/packs/general/capt-hook.toml +5 -0
  8. capt_hook-6.5.0/captain_hook/packs/general/detours.py +151 -0
  9. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/general/models.py +303 -16
  10. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/manager.py +2 -2
  11. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/primitives/llm.py +4 -4
  12. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/prompt.py +5 -6
  13. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/session.py +0 -6
  14. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/settings.py +14 -9
  15. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/state.py +0 -2
  16. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/testing/helpers.py +60 -44
  17. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/testing/session_cache.py +3 -1
  18. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/util/model_cache.py +2 -3
  19. {capt_hook-6.3.0 → capt_hook-6.5.0}/pyproject.toml +2 -2
  20. capt_hook-6.3.0/PKG-INFO +0 -137
  21. capt_hook-6.3.0/README.md +0 -89
  22. capt_hook-6.3.0/captain_hook/packs/general/capt-hook.toml +0 -5
  23. {capt_hook-6.3.0 → capt_hook-6.5.0}/LICENSE +0 -0
  24. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/.claude-plugin/plugin.json +0 -0
  25. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/__init__.py +0 -0
  26. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/__main__.py +0 -0
  27. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/app.py +0 -0
  28. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/ast_grep.py +0 -0
  29. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/classifiers/__init__.py +0 -0
  30. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/classifiers/conductor.py +0 -0
  31. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/classifiers/droid.py +0 -0
  32. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/classifiers/native.py +0 -0
  33. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/command.py +0 -0
  34. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/conditions.py +0 -0
  35. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/contexts.py +0 -0
  36. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/decisions.py +0 -0
  37. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/dispatch.py +0 -0
  38. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/events.py +0 -0
  39. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/fields.py +0 -0
  40. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/file.py +0 -0
  41. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/log.py +0 -0
  42. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/__init__.py +0 -0
  43. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/general/__init__.py +0 -0
  44. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/general/_lib.py +0 -0
  45. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/general/commands.py +0 -0
  46. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/general/docs.py +0 -0
  47. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/general/plans.py +0 -0
  48. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/general/prompts.py +0 -0
  49. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/general/review.py +0 -0
  50. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/general/tasks.py +0 -0
  51. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/general/tombstones.py +0 -0
  52. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/go/__init__.py +0 -0
  53. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/go/capt-hook.toml +0 -0
  54. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/go/testing.py +0 -0
  55. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/go/toolchain.py +0 -0
  56. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/python/__init__.py +0 -0
  57. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/python/capt-hook.toml +0 -0
  58. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/python/style.py +0 -0
  59. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/python/testing.py +0 -0
  60. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/python/toolchain.py +0 -0
  61. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/steering/__init__.py +0 -0
  62. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/steering/capt-hook.toml +0 -0
  63. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/packs/steering/steering.py +0 -0
  64. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/primitives/__init__.py +0 -0
  65. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/primitives/commands.py +0 -0
  66. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/primitives/lint.py +0 -0
  67. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/primitives/nudge.py +0 -0
  68. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/primitives/rewrite.py +0 -0
  69. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/primitives/workflow.py +0 -0
  70. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/py.typed +0 -0
  71. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/review/__init__.py +0 -0
  72. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/review/cli.py +0 -0
  73. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/review/dashboard.py +0 -0
  74. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/review/fix.py +0 -0
  75. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/review/formats.py +0 -0
  76. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/review/judge.py +0 -0
  77. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/review/pipeline.py +0 -0
  78. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/review/repo.py +0 -0
  79. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/review/scan.py +0 -0
  80. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/review/settings.py +0 -0
  81. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/review/store.py +0 -0
  82. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/review/sync.py +0 -0
  83. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/signals/__init__.py +0 -0
  84. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/signals/nlp.py +0 -0
  85. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/skills/authoring-hooks/SKILL.md +0 -0
  86. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +0 -0
  87. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +0 -0
  88. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
  89. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +0 -0
  90. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +0 -0
  91. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/skills/scanning-sessions/SKILL.md +0 -0
  92. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
  93. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
  94. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
  95. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
  96. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
  97. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
  98. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/style/__init__.py +0 -0
  99. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/style/ast_grep.py +0 -0
  100. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/style/matchers.py +0 -0
  101. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/style/scope.py +0 -0
  102. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/style/types.py +0 -0
  103. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/tasks.py +0 -0
  104. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
  105. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/testing/__init__.py +0 -0
  106. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/testing/types.py +0 -0
  107. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/types.py +0 -0
  108. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/util/__init__.py +0 -0
  109. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/util/fs.py +0 -0
  110. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/util/http.py +0 -0
  111. {capt_hook-6.3.0 → capt_hook-6.5.0}/captain_hook/util/text.py +0 -0
@@ -0,0 +1,146 @@
1
+ Metadata-Version: 2.4
2
+ Name: capt-hook
3
+ Version: 6.5.0
4
+ Summary: Stop repeating yourself to Claude.
5
+ Keywords: claude,claude-code,hooks,llm,agents,guardrails,cli
6
+ Author: Yasyf Mohamedali
7
+ Author-email: Yasyf Mohamedali <yasyfm@gmail.com>
8
+ License-Expression: PolyForm-Noncommercial-1.0.0
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Topic :: Software Development :: Quality Assurance
17
+ Classifier: Topic :: Software Development :: Testing
18
+ Classifier: Typing :: Typed
19
+ Requires-Dist: cc-transcript>=7.1,<8
20
+ Requires-Dist: pydantic>=2.0
21
+ Requires-Dist: pydantic-settings>=2.0
22
+ Requires-Dist: tree-sitter>=0.24
23
+ Requires-Dist: tree-sitter-bash>=0.23
24
+ Requires-Dist: ast-grep-py>=0.39,<1
25
+ Requires-Dist: funcy>=2.0
26
+ Requires-Dist: spacy>=3.7
27
+ Requires-Dist: click>=8
28
+ Requires-Dist: rich>=13
29
+ Requires-Dist: orjsonl>=1.0
30
+ Requires-Dist: wn>=1.1.0
31
+ Requires-Dist: lazy-object-proxy>=1.12.0
32
+ Requires-Dist: filelock>=3
33
+ Requires-Dist: loguru>=0.7.3
34
+ Requires-Dist: spawnllm>=0.5.2,<0.6
35
+ Requires-Dist: pytest>=8.0 ; extra == 'dev'
36
+ Requires-Dist: pytest-asyncio>=0.24 ; extra == 'dev'
37
+ Requires-Dist: pyright>=1.1 ; extra == 'dev'
38
+ Requires-Dist: pyyaml>=6 ; extra == 'dev'
39
+ Requires-Dist: ruff>=0.8 ; extra == 'dev'
40
+ Requires-Python: >=3.13
41
+ Project-URL: Homepage, https://github.com/yasyf/captain-hook
42
+ Project-URL: Documentation, https://yasyf.github.io/captain-hook/
43
+ Project-URL: Repository, https://github.com/yasyf/captain-hook
44
+ Project-URL: Issues, https://github.com/yasyf/captain-hook/issues
45
+ Project-URL: Changelog, https://github.com/yasyf/captain-hook/blob/main/CHANGELOG.md
46
+ Provides-Extra: dev
47
+ Description-Content-Type: text/markdown
48
+
49
+ # ![captain-hook](https://github.com/yasyf/captain-hook/raw/main/docs/assets/readme-banner.webp)
50
+
51
+ **Stop repeating yourself to Claude.** captain-hook mines your transcripts for the corrections you keep giving and opens PRs that turn each one into a typed, tested Python hook.
52
+
53
+ [![CI](https://github.com/yasyf/captain-hook/actions/workflows/ci.yml/badge.svg)](https://github.com/yasyf/captain-hook/actions/workflows/ci.yml)
54
+ [![PyPI](https://img.shields.io/pypi/v/capt-hook)](https://pypi.org/project/capt-hook/)
55
+ [![License: PolyForm Noncommercial](https://img.shields.io/badge/license-PolyForm--Noncommercial--1.0.0-blue)](https://github.com/yasyf/captain-hook/blob/main/LICENSE)
56
+
57
+ ## Get started
58
+
59
+ ```bash
60
+ uvx capt-hook init
61
+ ```
62
+
63
+ `init` scaffolds `.claude/hooks/`, wires Claude Code's settings, and arms the session reviewer. One `block_command` later, a force-push dies at `PreToolUse` and the hook's inline tests run green:
64
+
65
+ <img src="https://github.com/yasyf/captain-hook/raw/main/docs/assets/demo.png" alt="Terminal running 'uvx capt-hook test' — a hook blocks git push --force and both inline tests pass" width="700">
66
+
67
+ Driving with an agent? Paste this:
68
+
69
+ ```text
70
+ /plugin marketplace add yasyf/captain-hook
71
+ /plugin install captain-hook@captain-hook
72
+ ```
73
+
74
+ <details>
75
+ <summary>Prefer a prompt over the plugin?</summary>
76
+
77
+ ```text
78
+ Run `uvx capt-hook init` in this repo, write one hook that blocks force-pushes,
79
+ and verify it with `uvx capt-hook test`. Read https://yasyf.github.io/captain-hook/
80
+ if you get stuck.
81
+ ```
82
+
83
+ </details>
84
+
85
+ ---
86
+
87
+ ## Use cases
88
+
89
+ ### Block force-push and rm -rf before they run
90
+
91
+ One bad Bash call rewrites shared history or eats a directory, and by the time you spot it in the transcript it already ran. Declare the block once, tests inline:
92
+
93
+ ```python
94
+ # .claude/hooks/safety.py
95
+ from captain_hook import Allow, Block, Input, block_command
96
+
97
+ block_command(
98
+ ["git", "push", "--force"],
99
+ reason="Force-pushing rewrites shared history",
100
+ hint="Use `git push --force-with-lease` instead",
101
+ tests={
102
+ Input(command="git push --force"): Block(),
103
+ Input(command="git push origin main"): Allow(),
104
+ },
105
+ )
106
+ ```
107
+
108
+ The next `git push --force` never executes: the agent sees `BLOCKED: Force-pushing rewrites shared history` plus the hint, and reaches for `--force-with-lease` instead. `rm -rf` is one more `block_command` away.
109
+
110
+ ### Turn repeated corrections into rules Claude can't forget
111
+
112
+ You've typed "use uv, not pip" in a dozen sessions, and session thirteen makes the same mistake. After `init`, the session reviewer reads each transcript as the session ends, keeps the corrections that are standing rules, and — once a pattern proves itself across sessions — opens a PR that codifies it as a hook. Watch the pipeline:
113
+
114
+ ```bash
115
+ uvx capt-hook status
116
+ ```
117
+
118
+ The dashboard lists every correction it's tracking, staged from first sighting to open PR. You review the PR like any other; merged hooks enforce the rule from then on.
119
+
120
+ ### Gate "done" until the tests actually pass
121
+
122
+ The agent declares victory while the suite is red. A Stop gate holds the line:
123
+
124
+ ```python
125
+ # .claude/hooks/quality.py
126
+ from captain_hook import RanCommand, TouchedFile, gate
127
+
128
+ gate(
129
+ "You edited Python files but never ran the tests. Run `uv run pytest` before finishing.",
130
+ only_if=[TouchedFile("**/*.py")],
131
+ skip_if=[RanCommand(r"\bpytest\b")],
132
+ )
133
+ ```
134
+
135
+ The agent can't end the turn until a pytest run shows up in the transcript, and the gate stands down on its own once one does.
136
+
137
+ ## More in the docs
138
+
139
+ - **Session reviewer** — the full corrections lifecycle, from transcript to merged hook PR — [guide](https://yasyf.github.io/captain-hook/docs/guide/session-reviewer.html)
140
+ - **Conditions** — typed filters over tools, files, commands, and transcript history — [guide](https://yasyf.github.io/captain-hook/docs/guide/conditions.html)
141
+ - **LLM hooks** — gate on a model's verdict when a regex can't decide — [guide](https://yasyf.github.io/captain-hook/docs/guide/llm-hooks.html)
142
+ - **Workflows** — multi-step Stop gates with artifact checks and checklists — [guide](https://yasyf.github.io/captain-hook/docs/guide/workflows.html)
143
+ - **Packs** — the shipped `general`, `python`, and `go` hook packs — [guide](https://yasyf.github.io/captain-hook/docs/guide/packs.html)
144
+ - **Testing** — run `uvx capt-hook test --json` in CI so a regressed hook fails the build — [guide](https://yasyf.github.io/captain-hook/docs/guide/testing.html)
145
+
146
+ Read the [docs](https://yasyf.github.io/captain-hook/) for the full guide. Licensed under [PolyForm Noncommercial 1.0.0](https://github.com/yasyf/captain-hook/blob/main/LICENSE), free for noncommercial use.
@@ -0,0 +1,98 @@
1
+ # ![captain-hook](https://github.com/yasyf/captain-hook/raw/main/docs/assets/readme-banner.webp)
2
+
3
+ **Stop repeating yourself to Claude.** captain-hook mines your transcripts for the corrections you keep giving and opens PRs that turn each one into a typed, tested Python hook.
4
+
5
+ [![CI](https://github.com/yasyf/captain-hook/actions/workflows/ci.yml/badge.svg)](https://github.com/yasyf/captain-hook/actions/workflows/ci.yml)
6
+ [![PyPI](https://img.shields.io/pypi/v/capt-hook)](https://pypi.org/project/capt-hook/)
7
+ [![License: PolyForm Noncommercial](https://img.shields.io/badge/license-PolyForm--Noncommercial--1.0.0-blue)](https://github.com/yasyf/captain-hook/blob/main/LICENSE)
8
+
9
+ ## Get started
10
+
11
+ ```bash
12
+ uvx capt-hook init
13
+ ```
14
+
15
+ `init` scaffolds `.claude/hooks/`, wires Claude Code's settings, and arms the session reviewer. One `block_command` later, a force-push dies at `PreToolUse` and the hook's inline tests run green:
16
+
17
+ <img src="https://github.com/yasyf/captain-hook/raw/main/docs/assets/demo.png" alt="Terminal running 'uvx capt-hook test' — a hook blocks git push --force and both inline tests pass" width="700">
18
+
19
+ Driving with an agent? Paste this:
20
+
21
+ ```text
22
+ /plugin marketplace add yasyf/captain-hook
23
+ /plugin install captain-hook@captain-hook
24
+ ```
25
+
26
+ <details>
27
+ <summary>Prefer a prompt over the plugin?</summary>
28
+
29
+ ```text
30
+ Run `uvx capt-hook init` in this repo, write one hook that blocks force-pushes,
31
+ and verify it with `uvx capt-hook test`. Read https://yasyf.github.io/captain-hook/
32
+ if you get stuck.
33
+ ```
34
+
35
+ </details>
36
+
37
+ ---
38
+
39
+ ## Use cases
40
+
41
+ ### Block force-push and rm -rf before they run
42
+
43
+ One bad Bash call rewrites shared history or eats a directory, and by the time you spot it in the transcript it already ran. Declare the block once, tests inline:
44
+
45
+ ```python
46
+ # .claude/hooks/safety.py
47
+ from captain_hook import Allow, Block, Input, block_command
48
+
49
+ block_command(
50
+ ["git", "push", "--force"],
51
+ reason="Force-pushing rewrites shared history",
52
+ hint="Use `git push --force-with-lease` instead",
53
+ tests={
54
+ Input(command="git push --force"): Block(),
55
+ Input(command="git push origin main"): Allow(),
56
+ },
57
+ )
58
+ ```
59
+
60
+ The next `git push --force` never executes: the agent sees `BLOCKED: Force-pushing rewrites shared history` plus the hint, and reaches for `--force-with-lease` instead. `rm -rf` is one more `block_command` away.
61
+
62
+ ### Turn repeated corrections into rules Claude can't forget
63
+
64
+ You've typed "use uv, not pip" in a dozen sessions, and session thirteen makes the same mistake. After `init`, the session reviewer reads each transcript as the session ends, keeps the corrections that are standing rules, and — once a pattern proves itself across sessions — opens a PR that codifies it as a hook. Watch the pipeline:
65
+
66
+ ```bash
67
+ uvx capt-hook status
68
+ ```
69
+
70
+ The dashboard lists every correction it's tracking, staged from first sighting to open PR. You review the PR like any other; merged hooks enforce the rule from then on.
71
+
72
+ ### Gate "done" until the tests actually pass
73
+
74
+ The agent declares victory while the suite is red. A Stop gate holds the line:
75
+
76
+ ```python
77
+ # .claude/hooks/quality.py
78
+ from captain_hook import RanCommand, TouchedFile, gate
79
+
80
+ gate(
81
+ "You edited Python files but never ran the tests. Run `uv run pytest` before finishing.",
82
+ only_if=[TouchedFile("**/*.py")],
83
+ skip_if=[RanCommand(r"\bpytest\b")],
84
+ )
85
+ ```
86
+
87
+ The agent can't end the turn until a pytest run shows up in the transcript, and the gate stands down on its own once one does.
88
+
89
+ ## More in the docs
90
+
91
+ - **Session reviewer** — the full corrections lifecycle, from transcript to merged hook PR — [guide](https://yasyf.github.io/captain-hook/docs/guide/session-reviewer.html)
92
+ - **Conditions** — typed filters over tools, files, commands, and transcript history — [guide](https://yasyf.github.io/captain-hook/docs/guide/conditions.html)
93
+ - **LLM hooks** — gate on a model's verdict when a regex can't decide — [guide](https://yasyf.github.io/captain-hook/docs/guide/llm-hooks.html)
94
+ - **Workflows** — multi-step Stop gates with artifact checks and checklists — [guide](https://yasyf.github.io/captain-hook/docs/guide/workflows.html)
95
+ - **Packs** — the shipped `general`, `python`, and `go` hook packs — [guide](https://yasyf.github.io/captain-hook/docs/guide/packs.html)
96
+ - **Testing** — run `uvx capt-hook test --json` in CI so a regressed hook fails the build — [guide](https://yasyf.github.io/captain-hook/docs/guide/testing.html)
97
+
98
+ Read the [docs](https://yasyf.github.io/captain-hook/) for the full guide. Licensed under [PolyForm Noncommercial 1.0.0](https://github.com/yasyf/captain-hook/blob/main/LICENSE), free for noncommercial use.
@@ -501,7 +501,9 @@ def run_tests(json_output: bool = False) -> None:
501
501
  @click.pass_context
502
502
  def cli(ctx: click.Context, hooks: str | None, root_path: str | None) -> None:
503
503
  """Captain Hook — declarative hook framework for Claude Code lifecycle events."""
504
- root = Path(root_path) if root_path else Path(env) if (env := os.environ.get("CLAUDE_PROJECT_DIR")) else Path.cwd()
504
+ from captain_hook.settings import resolve_project_dir
505
+
506
+ root = Path(root_path) if root_path else Path(p) if (p := resolve_project_dir()) else Path.cwd()
505
507
  ctx.obj = CliState(root=root, hooks=hooks or str(root / ".claude" / "hooks"))
506
508
 
507
509
 
@@ -21,6 +21,7 @@ from spawnllm.proc import run_cli
21
21
  from captain_hook.classifiers import detect
22
22
  from captain_hook.prompt import Prompt
23
23
  from captain_hook.session import SessionStore
24
+ from captain_hook.settings import resolve_project_dir
24
25
 
25
26
  if TYPE_CHECKING:
26
27
  from collections.abc import Sequence
@@ -78,7 +79,7 @@ def lift_session(events: Sequence[TranscriptEvent], *, path: Path | None = None)
78
79
  from captain_hook.app import _state
79
80
 
80
81
  classifier = _state.classifier or detect(
81
- cwd=os.environ.get("CLAUDE_PROJECT_DIR") or os.environ.get("FACTORY_PROJECT_DIR"),
82
+ cwd=resolve_project_dir(),
82
83
  transcript_path=str(path) if path else None,
83
84
  events=events,
84
85
  )
@@ -185,7 +186,7 @@ class HookContext:
185
186
  input=input,
186
187
  timeout=timeout,
187
188
  env=os.environ | (env or {}),
188
- cwd=os.environ.get("CLAUDE_PROJECT_DIR") or os.environ.get("FACTORY_PROJECT_DIR"),
189
+ cwd=resolve_project_dir(),
189
190
  )
190
191
  except (OSError, subprocess.SubprocessError):
191
192
  if throw:
@@ -309,7 +310,7 @@ class HookContext:
309
310
  ) -> str | BaseModel:
310
311
  diff_text = self.diff("uncommitted" if diff is True else diff) if diff else None
311
312
  prompt = self.assemble_prompt(template, args, kwargs, transcript=transcript, diff_text=diff_text)
312
- cwd = os.environ.get("CLAUDE_PROJECT_DIR") or os.environ.get("FACTORY_PROJECT_DIR")
313
+ cwd = resolve_project_dir()
313
314
  if response_model is not None:
314
315
  return extract_sync(
315
316
  prompt, response_model, specialty=specialty, model=model, agent=agent, cwd=cwd, timeout=timeout
@@ -26,13 +26,11 @@ if TYPE_CHECKING:
26
26
 
27
27
  Scope = Literal["project", "global"]
28
28
 
29
- DURABLE_ROOT_OVERRIDE: list[Path] = []
30
-
31
29
 
32
30
  def durable_root() -> Path:
33
31
  from captain_hook.settings import resolve_state_dir
34
32
 
35
- return DURABLE_ROOT_OVERRIDE[-1] if DURABLE_ROOT_OVERRIDE else resolve_state_dir() / "hooks" / "durable"
33
+ return resolve_state_dir() / "hooks" / "durable"
36
34
 
37
35
 
38
36
  def project_key(repo_root: Path) -> str:
@@ -98,11 +96,6 @@ class DurableStore:
98
96
  if model not in cls.TRACKED:
99
97
  cls.TRACKED.append(model)
100
98
 
101
- @classmethod
102
- def untrack(cls, model: type[BaseModel]) -> None:
103
- if model in cls.TRACKED:
104
- cls.TRACKED.remove(model)
105
-
106
99
  @classmethod
107
100
  def tracked_models(cls) -> Sequence[type[BaseModel]]:
108
101
  return tuple(cls.TRACKED)
@@ -13,7 +13,7 @@ from typing import TYPE_CHECKING
13
13
 
14
14
  from loguru import logger
15
15
 
16
- from captain_hook.app import State, _state, on
16
+ from captain_hook.app import _state, on
17
17
  from captain_hook.settings import build_settings
18
18
  from captain_hook.types import Event
19
19
 
@@ -47,8 +47,7 @@ def import_or_reload(fqn: str, fresh_this_pass: set[str]) -> ModuleType:
47
47
  return mod
48
48
 
49
49
 
50
- def discover_hooks(hooks_dir: str | Path, state: State | None = None) -> None:
51
- target = state or _state
50
+ def discover_hooks(hooks_dir: str | Path) -> None:
52
51
  hooks_path = Path(hooks_dir).resolve()
53
52
  if str(hooks_path.parent) not in sys.path:
54
53
  sys.path.insert(0, str(hooks_path.parent))
@@ -60,9 +59,9 @@ def discover_hooks(hooks_dir: str | Path, state: State | None = None) -> None:
60
59
 
61
60
  if CONF_MODULE in top_level:
62
61
  conf_module = import_or_reload(f"{pkg}.{CONF_MODULE}", fresh_this_pass)
63
- target.settings = build_settings(conf_module)
62
+ _state.settings = build_settings(conf_module)
64
63
  if classifier := getattr(conf_module, "classifier", None):
65
- target.classifier = classifier
64
+ _state.classifier = classifier
66
65
 
67
66
  all_modules = {
68
67
  info.name
@@ -80,7 +79,7 @@ def discover_hooks(hooks_dir: str | Path, state: State | None = None) -> None:
80
79
  import_or_reload(fqn, fresh_this_pass)
81
80
  except Exception as exc:
82
81
  logger.bind(module=fqn).opt(exception=True).warning("skipped unloadable hook module")
83
- target.load_errors.append((fqn, exc))
82
+ _state.load_errors.append((fqn, exc))
84
83
 
85
84
 
86
85
  def ensure_pack_package(fqn: str, search_paths: list[str]) -> ModuleType:
@@ -0,0 +1,5 @@
1
+ name = "general"
2
+ version = "0.10.0"
3
+ description = "Language-agnostic guards: git/command safety, doc & prompt nudges, task & plan discipline, code-review + docs-freshness gates, and a detour check-in nudge."
4
+ hooks = "."
5
+ nlp = true
@@ -0,0 +1,151 @@
1
+ from __future__ import annotations
2
+
3
+ from captain_hook import (
4
+ Allow,
5
+ Event,
6
+ Input,
7
+ Signal,
8
+ Signals,
9
+ Tool,
10
+ Warn,
11
+ llm_nudge,
12
+ )
13
+
14
+ llm_nudge(
15
+ """You are a senior engineer watching another engineer ("the agent") mid-task. You are
16
+ running in agent mode in the project's working directory, with read tools. Your one job:
17
+ decide whether the agent has veered onto an UNREQUESTED DETOUR — side work nobody asked
18
+ for — without checking in first.
19
+
20
+ Read first, judge second:
21
+ - The session transcript is rendered above inside `<transcript path="...">`; long content is
22
+ clipped (you'll see `…(+Nch)` markers). From the full history (prefer `cc-transcript
23
+ show`/`grep`; else read the file) establish: (1) what was actually ASKED — the user's
24
+ original request plus any later redirections or standing permissions — and (2) what the
25
+ agent is doing RIGHT NOW — the just-run tool call and the last few assistant messages.
26
+
27
+ Discriminator: the current work is either (a) the requested task, (b) a necessary
28
+ prerequisite of it (the task cannot land or be verified without it), or (c) authorized —
29
+ the user said some form of "also…", "while you're there…", "fix anything you find", or it
30
+ is a small stewardship fix inside code the task already touches. Any of those -> fire=false.
31
+ Otherwise — the agent noticed something adjacent and started acting on it without surfacing
32
+ it — fire=true.
33
+
34
+ Detour tells (lean fire=true): "while I'm here" / "might as well" / "let me also" followed
35
+ by edits to files the task doesn't need; fixing or refactoring code the request never
36
+ mentioned and the task doesn't depend on; a cleanup sweep starting mid-task; chasing a side
37
+ mystery at length while the requested work sits unfinished.
38
+
39
+ Do NOT fire when: the side work blocks the task (a broken build or failing test the change
40
+ trips over); it's a small stewardship fix in a file already being edited for the task; the
41
+ user pre-authorized it; the agent is gathering context it needs; or the agent already
42
+ surfaced the discovery and offered options instead of acting.
43
+
44
+ <examples>
45
+ <example fire="true">
46
+ Asked: "rename the config flag". Agent: "While I'm here, the retry logic in client.py looks
47
+ wrong — let me fix that too", then edits client.py.
48
+ Unrequested fix in a file the rename never touches.
49
+ </example>
50
+ <example fire="true">
51
+ Asked: "add a --json flag". Agent: "I also noticed the error handling is inconsistent
52
+ across commands; I'll clean that up as well", then starts a multi-file sweep.
53
+ Scope expansion nobody asked for, no options offered.
54
+ </example>
55
+ <example fire="false">
56
+ Asked: "fix the failing test". Agent: "The fixture helper it calls has the actual bug —
57
+ fixing that first."
58
+ A prerequisite: the requested fix cannot land without it.
59
+ </example>
60
+ <example fire="false">
61
+ User said "clean up anything you find along the way"; agent fixes a stale docstring in a
62
+ file it was already editing.
63
+ Pre-authorized stewardship.
64
+ </example>
65
+ <example fire="false">
66
+ Agent: "I noticed X while working on Y — options: (1) finish Y and file X as a follow-up,
67
+ (2) fix X now, (3) ignore it. Which?"
68
+ Already surfacing options instead of acting.
69
+ </example>
70
+ </examples>
71
+
72
+ When uncertain, return fire=false. A missed detour costs one review comment; a false alarm
73
+ on legitimate work teaches the agent to ignore this nudge. Fire only when the current action
74
+ is clearly outside both the request and its prerequisites. Put your reasoning (under 50
75
+ words, naming the detour and the requested task) in `reasoning`.""",
76
+ message=lambda r: (
77
+ f"This looks like a detour — side work nobody asked for. {r.reasoning} "
78
+ "Stop and check in before acting: say what you noticed, then propose 2-4 concrete "
79
+ "options (finish the task and file this as a follow-up; pause and fix it now; ignore "
80
+ "it) and let the user pick — or, if you are a delegated agent, stop and return early "
81
+ "with findings plus options for your orchestrator instead of improvising. "
82
+ "See: AGENTS.md § Ask Before Assuming."
83
+ ),
84
+ only_if=[Tool("Edit|Write|MultiEdit|NotebookEdit|Bash")],
85
+ events=Event.PostToolUse,
86
+ signals=Signals(
87
+ [
88
+ Signal(pattern=r"(?i)\bwhile (?:I'm|I am|we're|we are) (?:here|at it|in (?:here|there))\b", weight=2),
89
+ Signal(pattern=r"(?i)\bmight as well\b", weight=2),
90
+ Signal(pattern=r"(?i)\b(?:let me|I'll|I will) also\b", weight=2),
91
+ Signal(pattern=r"(?i)\bas a bonus\b", weight=2),
92
+ Signal(pattern=r"(?i)\bI (?:also )?noticed\b", weight=1),
93
+ Signal(pattern=r"(?i)\b(?:unrelated|a side note|tangent)\b", weight=1),
94
+ Signal(pattern=r"(?i)\bone more thing\b", weight=1),
95
+ Signal(pattern=r"(?i)\bquick(?:ly)? (?:fix|clean|tidy|refactor)\w*\b", weight=1),
96
+ ],
97
+ threshold=2,
98
+ window=8,
99
+ ),
100
+ tests={
101
+ Input(
102
+ file="client.py",
103
+ content="retry = 3\n",
104
+ transcript=[
105
+ {
106
+ "type": "assistant",
107
+ "message": {
108
+ "content": [
109
+ {"type": "text", "text": "While I'm here, the retry logic looks wrong — fixing it too."}
110
+ ]
111
+ },
112
+ }
113
+ ],
114
+ ): Warn(pattern="detour"),
115
+ Input(
116
+ command="./scripts/cleanup.sh",
117
+ transcript=[
118
+ {
119
+ "type": "assistant",
120
+ "message": {
121
+ "content": [
122
+ {"type": "text", "text": "I also noticed stale artifacts. One more thing to clean up."}
123
+ ]
124
+ },
125
+ }
126
+ ],
127
+ ): Warn(pattern="options"),
128
+ Input(
129
+ file="flags.py",
130
+ content="json_flag = True\n",
131
+ transcript=[
132
+ {
133
+ "type": "assistant",
134
+ "message": {"content": [{"type": "text", "text": "Implementing the requested --json flag now."}]},
135
+ }
136
+ ],
137
+ ): Allow(),
138
+ Input(
139
+ tool="Read",
140
+ file="client.py",
141
+ transcript=[
142
+ {
143
+ "type": "assistant",
144
+ "message": {
145
+ "content": [{"type": "text", "text": "While I'm here, might as well look at the retry logic."}]
146
+ },
147
+ }
148
+ ],
149
+ ): Allow(),
150
+ },
151
+ )