capt-hook 3.3.2__tar.gz → 3.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.
- capt_hook-3.5.0/PKG-INFO +136 -0
- capt_hook-3.5.0/README.md +89 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/cli.py +35 -39
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/review/cli.py +14 -3
- capt_hook-3.5.0/captain_hook/review/dashboard.py +208 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/review/pipeline.py +11 -2
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/review/store.py +112 -16
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/review/sync.py +5 -2
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/skills/authoring-hooks/SKILL.md +1 -1
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +1 -1
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +2 -2
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/skills/scanning-sessions/SKILL.md +3 -3
- {capt_hook-3.3.2 → capt_hook-3.5.0}/pyproject.toml +2 -1
- capt_hook-3.3.2/PKG-INFO +0 -152
- capt_hook-3.3.2/README.md +0 -106
- {capt_hook-3.3.2 → capt_hook-3.5.0}/LICENSE +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/.claude-plugin/plugin.json +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/__init__.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/__main__.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/app.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/classifiers/__init__.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/classifiers/conductor.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/classifiers/droid.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/classifiers/native.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/command.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/conditions.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/context.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/decisions.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/dispatch.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/events.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/file.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/llm/__init__.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/loader.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/log.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/packs/__init__.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/packs/general/capt-hook.toml +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/packs/general/commands.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/packs/general/docs.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/packs/general/plans.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/packs/general/prompts.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/packs/general/review.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/packs/general/stewardship.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/packs/general/tasks.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/packs/manager.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/packs/python/capt-hook.toml +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/packs/python/style.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/packs/python/testing.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/packs/python/toolchain.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/primitives/__init__.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/primitives/commands.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/primitives/lint.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/primitives/llm.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/primitives/nudge.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/primitives/workflow.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/prompt.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/py.typed +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/review/__init__.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/review/fix.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/review/formats.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/review/judge.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/review/repo.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/review/scan.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/review/settings.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/session.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/settings.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/signals/__init__.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/signals/nlp.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/state.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/style/__init__.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/style/matchers.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/style/scope.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/style/types.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/tasks.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/testing/__init__.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/testing/helpers.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/testing/session_cache.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/testing/types.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/tests/__init__.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/tests/helpers.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/types.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/util/__init__.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/util/model_cache.py +0 -0
- {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/utils.py +0 -0
capt_hook-3.5.0/PKG-INFO
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: capt-hook
|
|
3
|
+
Version: 3.5.0
|
|
4
|
+
Summary: Declarative hook framework for Claude Code
|
|
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>=3.2,<4
|
|
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: funcy>=2.0
|
|
25
|
+
Requires-Dist: spacy>=3.7
|
|
26
|
+
Requires-Dist: click>=8
|
|
27
|
+
Requires-Dist: rich>=13
|
|
28
|
+
Requires-Dist: orjsonl>=1.0
|
|
29
|
+
Requires-Dist: wn>=1.1.0
|
|
30
|
+
Requires-Dist: lazy-object-proxy>=1.12.0
|
|
31
|
+
Requires-Dist: filelock>=3
|
|
32
|
+
Requires-Dist: loguru>=0.7.3
|
|
33
|
+
Requires-Dist: spawnllm>=0.1.3
|
|
34
|
+
Requires-Dist: pytest>=8.0 ; extra == 'dev'
|
|
35
|
+
Requires-Dist: pytest-asyncio>=0.24 ; extra == 'dev'
|
|
36
|
+
Requires-Dist: pyright>=1.1 ; extra == 'dev'
|
|
37
|
+
Requires-Dist: pyyaml>=6 ; extra == 'dev'
|
|
38
|
+
Requires-Dist: ruff>=0.8 ; extra == 'dev'
|
|
39
|
+
Requires-Python: >=3.13
|
|
40
|
+
Project-URL: Homepage, https://github.com/yasyf/captain-hook
|
|
41
|
+
Project-URL: Documentation, https://yasyf.github.io/captain-hook/
|
|
42
|
+
Project-URL: Repository, https://github.com/yasyf/captain-hook
|
|
43
|
+
Project-URL: Issues, https://github.com/yasyf/captain-hook/issues
|
|
44
|
+
Project-URL: Changelog, https://github.com/yasyf/captain-hook/blob/main/CHANGELOG.md
|
|
45
|
+
Provides-Extra: dev
|
|
46
|
+
Description-Content-Type: text/markdown
|
|
47
|
+
|
|
48
|
+
# captain-hook
|
|
49
|
+
|
|
50
|
+

|
|
51
|
+
|
|
52
|
+
[](https://pypi.org/project/capt-hook/)
|
|
53
|
+
[](https://pypi.org/project/capt-hook/)
|
|
54
|
+
[](https://yasyf.github.io/captain-hook/)
|
|
55
|
+
[](https://github.com/yasyf/captain-hook/blob/main/LICENSE)
|
|
56
|
+
|
|
57
|
+
Guardrails for Claude Code, written as typed, testable data — and learned from the corrections you give Claude.
|
|
58
|
+
|
|
59
|
+
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.
|
|
60
|
+
|
|
61
|
+
## Install
|
|
62
|
+
|
|
63
|
+
captain-hook needs no install — it runs through [uvx](https://docs.astral.sh/uv/). From your project root:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
uvx capt-hook init
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`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".
|
|
70
|
+
|
|
71
|
+
## Your first hook
|
|
72
|
+
|
|
73
|
+
A hook is an event, some conditions, and an action. This one stops the agent from finishing a UI change it never looked at:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
# .claude/hooks/visual_review.py
|
|
77
|
+
from captain_hook import gate, TouchedFile, UsedSkill
|
|
78
|
+
|
|
79
|
+
gate(
|
|
80
|
+
"You edited UI files. Open them with agent-browser and verify they render before finishing.",
|
|
81
|
+
only_if=[TouchedFile("**/src/routes/**", "**/src/components/**")],
|
|
82
|
+
skip_if=[UsedSkill("agent-browser")],
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`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.
|
|
87
|
+
|
|
88
|
+
## It learns from your corrections
|
|
89
|
+
|
|
90
|
+
Most hooks you'll never write by hand.
|
|
91
|
+
|
|
92
|
+
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.
|
|
93
|
+
|
|
94
|
+
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.
|
|
95
|
+
|
|
96
|
+
## Tested like code
|
|
97
|
+
|
|
98
|
+
Every deterministic hook carries inline tests, so a broken hook fails like broken code:
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
# .claude/hooks/safety.py
|
|
102
|
+
from captain_hook import Allow, Block, Input, block_command
|
|
103
|
+
|
|
104
|
+
block_command(
|
|
105
|
+
["git", "stash"],
|
|
106
|
+
reason="Use the team's VCS workflow for shelving changes",
|
|
107
|
+
hint="Commit a WIP change instead of stashing",
|
|
108
|
+
tests={
|
|
109
|
+
Input(command="git stash"): Block(),
|
|
110
|
+
Input(command="git status"): Allow(),
|
|
111
|
+
},
|
|
112
|
+
)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Run them from your project root, where `--hooks` defaults to `.claude/hooks`:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
uvx capt-hook test
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Wire that into CI and you catch a broken hook the way you catch broken code.
|
|
122
|
+
|
|
123
|
+
## What it's for
|
|
124
|
+
|
|
125
|
+
- Block footguns before they run on `PreToolUse`: force-push, `rm -rf`, package-manager traps.
|
|
126
|
+
- Steer the agent with feedback that fires on the patterns it actually emits: repeated failures, weakened tests, missed conventions.
|
|
127
|
+
- 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.
|
|
128
|
+
- Keep all of it testable; every hook ships with inline tests that run in CI.
|
|
129
|
+
|
|
130
|
+
## Docs
|
|
131
|
+
|
|
132
|
+
[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/).
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
Licensed under [PolyForm Noncommercial 1.0.0](LICENSE), free for noncommercial use.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# captain-hook
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
[](https://pypi.org/project/capt-hook/)
|
|
6
|
+
[](https://pypi.org/project/capt-hook/)
|
|
7
|
+
[](https://yasyf.github.io/captain-hook/)
|
|
8
|
+
[](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.
|
|
@@ -54,40 +54,24 @@ def example_hook_source() -> str:
|
|
|
54
54
|
return (importlib.resources.files("captain_hook") / "templates" / "example_hook.py.tmpl").read_text()
|
|
55
55
|
|
|
56
56
|
|
|
57
|
-
def
|
|
58
|
-
"""
|
|
57
|
+
def plugin_dir() -> Path:
|
|
58
|
+
"""Filesystem path to the bundled captain-hook plugin root.
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
force: Replace existing skill directories wholesale instead of skipping them.
|
|
63
|
-
|
|
64
|
-
Returns:
|
|
65
|
-
Per-skill status of ``"installed"``, ``"replaced"``, or ``"skipped"``.
|
|
60
|
+
Holds ``.claude-plugin/plugin.json`` and ``skills/``, so ``claude --plugin-dir``
|
|
61
|
+
can load the skills in-place from the installed wheel without a marketplace clone.
|
|
66
62
|
"""
|
|
67
|
-
|
|
68
|
-
summary: dict[str, str] = {}
|
|
69
|
-
with importlib.resources.as_file(importlib.resources.files("captain_hook") / "skills") as src_root:
|
|
70
|
-
for skill in sorted(p for p in src_root.iterdir() if p.is_dir()):
|
|
71
|
-
dest = dest_root / skill.name
|
|
72
|
-
if dest.exists() and not force:
|
|
73
|
-
summary[skill.name] = "skipped"
|
|
74
|
-
continue
|
|
75
|
-
if dest.exists():
|
|
76
|
-
shutil.rmtree(dest)
|
|
77
|
-
summary[skill.name] = "replaced"
|
|
78
|
-
else:
|
|
79
|
-
summary[skill.name] = "installed"
|
|
80
|
-
shutil.copytree(skill, dest)
|
|
81
|
-
return summary
|
|
63
|
+
return Path(str(importlib.resources.files("captain_hook")))
|
|
82
64
|
|
|
83
65
|
|
|
84
66
|
def register_marketplace(root: Path) -> None:
|
|
85
|
-
"""Enable the captain-hook plugin marketplace in ``root/.claude/settings.
|
|
67
|
+
"""Enable the captain-hook plugin marketplace in ``root/.claude/settings.json``.
|
|
86
68
|
|
|
87
69
|
Merges ``extraKnownMarketplaces`` and ``enabledPlugins`` entries into the
|
|
88
|
-
|
|
70
|
+
committed settings so the skills load from the plugin (tracking the repository)
|
|
71
|
+
instead of being copied into ``.claude/skills``. Claude Code prompts to install
|
|
72
|
+
the plugin when the project folder is trusted.
|
|
89
73
|
"""
|
|
90
|
-
settings_path = root / ".claude" / "settings.
|
|
74
|
+
settings_path = root / ".claude" / "settings.json"
|
|
91
75
|
existing = json.loads(settings_path.read_text()) if settings_path.exists() else {}
|
|
92
76
|
write_settings(
|
|
93
77
|
settings_path,
|
|
@@ -104,7 +88,9 @@ def maybe_launch_bootstrap(root: Path) -> bool:
|
|
|
104
88
|
|
|
105
89
|
Only fires in an interactive session with the ``claude`` CLI on PATH; CI and
|
|
106
90
|
scripted runs skip the prompt entirely. On acceptance, the captain-hook plugin
|
|
107
|
-
marketplace is registered in ``.claude/settings.
|
|
91
|
+
marketplace is registered in ``.claude/settings.json``, and Claude is launched
|
|
92
|
+
with the bundled plugin loaded via ``--plugin-dir`` so the namespaced skill
|
|
93
|
+
resolves immediately without waiting on a marketplace install.
|
|
108
94
|
|
|
109
95
|
Returns:
|
|
110
96
|
Whether Claude was launched.
|
|
@@ -114,7 +100,9 @@ def maybe_launch_bootstrap(root: Path) -> bool:
|
|
|
114
100
|
if not click.confirm("Bootstrap hooks now? (launches Claude with the bootstrapping-hooks skill)", default=True):
|
|
115
101
|
return False
|
|
116
102
|
register_marketplace(root)
|
|
117
|
-
subprocess.run(
|
|
103
|
+
subprocess.run(
|
|
104
|
+
["claude", "--plugin-dir", str(plugin_dir()), "/captain-hook:bootstrapping-hooks"], cwd=root, check=False
|
|
105
|
+
)
|
|
118
106
|
return True
|
|
119
107
|
|
|
120
108
|
|
|
@@ -326,17 +314,14 @@ def init_project(root: Path, *, review: bool = True) -> None:
|
|
|
326
314
|
merged, summary = merge_settings(".claude/hooks", settings_path)
|
|
327
315
|
write_settings(settings_path, merged)
|
|
328
316
|
|
|
329
|
-
|
|
317
|
+
register_marketplace(root)
|
|
330
318
|
|
|
331
319
|
click.echo(f"Scaffolded {example.relative_to(root)} + {settings_path.relative_to(root)}.")
|
|
332
320
|
click.echo()
|
|
333
321
|
print_hook_summary(str(settings_path.relative_to(root)), summary)
|
|
334
322
|
click.echo()
|
|
335
|
-
click.echo("
|
|
336
|
-
|
|
337
|
-
click.echo(f" + installed {name}")
|
|
338
|
-
if skipped := [n for n, status in skills_summary.items() if status == "skipped"]:
|
|
339
|
-
click.echo(f" unchanged: {', '.join(skipped)} (already present; capt-hook skills install --force to refresh)")
|
|
323
|
+
click.echo("Claude Code plugin:")
|
|
324
|
+
click.echo(f" + registered {PLUGIN_ID} in .claude/settings.json (skills install on folder-trust)")
|
|
340
325
|
click.echo()
|
|
341
326
|
match (review, repo_key(root)):
|
|
342
327
|
case (False, _):
|
|
@@ -527,18 +512,29 @@ def logs(session: str | None, tail: int | None) -> None:
|
|
|
527
512
|
show_logs(session=session, tail=tail)
|
|
528
513
|
|
|
529
514
|
|
|
515
|
+
@cli.command()
|
|
516
|
+
@click.option("--repo", "repo_", default=None, help="Repo key (default: the current repo)")
|
|
517
|
+
@click.option("--sync/--no-sync", default=True, help="Refresh open PR states from GitHub in the background")
|
|
518
|
+
@click.pass_obj
|
|
519
|
+
def status(state: CliState, repo_: str | None, sync: bool) -> None:
|
|
520
|
+
"""Show the corrections the session reviewer is tracking and the hook PRs they would open."""
|
|
521
|
+
from captain_hook.review.cli import resolve_repo
|
|
522
|
+
from captain_hook.review.dashboard import status_command
|
|
523
|
+
|
|
524
|
+
status_command(resolve_repo(repo_, state.root), sync=sync)
|
|
525
|
+
|
|
526
|
+
|
|
530
527
|
@cli.group()
|
|
531
528
|
def skills() -> None:
|
|
532
529
|
"""Manage the bundled Claude Code skills."""
|
|
533
530
|
|
|
534
531
|
|
|
535
532
|
@skills.command(name="install")
|
|
536
|
-
@click.option("--force", is_flag=True, default=False, help="Replace skills that already exist in .claude/skills")
|
|
537
533
|
@click.pass_obj
|
|
538
|
-
def skills_install(state: CliState
|
|
539
|
-
"""
|
|
540
|
-
|
|
541
|
-
|
|
534
|
+
def skills_install(state: CliState) -> None:
|
|
535
|
+
"""Register the captain-hook plugin in .claude/settings.json (skills load from the plugin, not copied files)."""
|
|
536
|
+
register_marketplace(state.root)
|
|
537
|
+
click.echo(f" registered {PLUGIN_ID} in .claude/settings.json")
|
|
542
538
|
|
|
543
539
|
|
|
544
540
|
@cli.group()
|
|
@@ -120,12 +120,12 @@ def spawn(transcript: Path, cwd: str | None) -> None:
|
|
|
120
120
|
@review.command()
|
|
121
121
|
@click.pass_obj
|
|
122
122
|
def enable(state: CliState) -> None:
|
|
123
|
-
"""Watch the current repo,
|
|
124
|
-
from captain_hook.cli import
|
|
123
|
+
"""Watch the current repo, register the captain-hook plugin, and wire the SessionEnd hook."""
|
|
124
|
+
from captain_hook.cli import register_marketplace
|
|
125
125
|
|
|
126
126
|
repo = current_repo(state.root)
|
|
127
127
|
watch_repo(repo)
|
|
128
|
-
|
|
128
|
+
register_marketplace(state.root)
|
|
129
129
|
wired = ensure_review_wiring(state.root / ".claude" / "settings.local.json")
|
|
130
130
|
click.echo(f"watching {repo}" + (" (SessionEnd hook wired into .claude/settings.local.json)" if wired else ""))
|
|
131
131
|
|
|
@@ -198,6 +198,17 @@ def triage(limit: int | None) -> None:
|
|
|
198
198
|
click.echo(f"judged {report.judged}, failed {report.failed}, pending {report.pending}")
|
|
199
199
|
|
|
200
200
|
|
|
201
|
+
@review.command(name="status")
|
|
202
|
+
@click.option("--repo", "repo_", default=None, help="Repo key (default: the current repo)")
|
|
203
|
+
@click.option("--sync/--no-sync", default=True, help="Refresh open PR states from GitHub in the background")
|
|
204
|
+
@click.pass_obj
|
|
205
|
+
def status(state: CliState, repo_: str | None, sync: bool) -> None:
|
|
206
|
+
"""Show the tracked corrections, their progress toward a PR, and open PR status."""
|
|
207
|
+
from captain_hook.review.dashboard import status_command
|
|
208
|
+
|
|
209
|
+
status_command(resolve_repo(repo_, state.root), sync=sync)
|
|
210
|
+
|
|
211
|
+
|
|
201
212
|
@review.command(name="list")
|
|
202
213
|
@click.option("--repo", "repo_", default=None, help="Repo key (default: the current repo)")
|
|
203
214
|
@click.pass_obj
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""The ``capt-hook status`` dashboard: the corrections lifecycle, rendered.
|
|
2
|
+
|
|
3
|
+
Reads the reviewer's :class:`~captain_hook.review.store.ReviewStore` and renders
|
|
4
|
+
every candidate the reviewer tracks, bucketed by lifecycle stage — watching
|
|
5
|
+
(building toward the bar), eligible (a PR opens next session), PR open, and the
|
|
6
|
+
merged/closed/stale outcomes. Each row shows kind-aware progress toward its PR
|
|
7
|
+
thresholds and the one-sentence summary of what its PR would do. Open PRs are
|
|
8
|
+
shown from the last-synced state first, then refreshed against GitHub in the
|
|
9
|
+
background so the view appears instantly and updates when ``gh`` returns.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
from datetime import UTC, datetime
|
|
16
|
+
from enum import StrEnum
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from rich.console import Console, Group
|
|
20
|
+
from rich.spinner import Spinner
|
|
21
|
+
from rich.text import Text
|
|
22
|
+
|
|
23
|
+
from captain_hook.review.store import CandidateKind, CandidateStatus
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from rich.console import RenderableType
|
|
27
|
+
|
|
28
|
+
from captain_hook.review.repo import RepoKey
|
|
29
|
+
from captain_hook.review.settings import ReviewSettings
|
|
30
|
+
from captain_hook.review.store import CandidateView
|
|
31
|
+
|
|
32
|
+
BAR_FILLED = "█"
|
|
33
|
+
BAR_EMPTY = "░"
|
|
34
|
+
DETAIL_WIDTH = 80
|
|
35
|
+
KIND_STYLE = {CandidateKind.CREATE: "cyan", CandidateKind.FIX: "magenta"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Stage(StrEnum):
|
|
39
|
+
"""A candidate's dashboard bucket — its lifecycle status, with watching split by eligibility."""
|
|
40
|
+
|
|
41
|
+
WATCHING = "watching"
|
|
42
|
+
ELIGIBLE = "eligible"
|
|
43
|
+
PR_OPEN = "pr_open"
|
|
44
|
+
ACCEPTED = "accepted"
|
|
45
|
+
REJECTED = "rejected"
|
|
46
|
+
STALE = "stale"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
SECTIONS: tuple[tuple[Stage, str, str, str], ...] = (
|
|
50
|
+
(Stage.WATCHING, "WATCHING", "building toward the bar", "yellow"),
|
|
51
|
+
(Stage.ELIGIBLE, "ELIGIBLE", "a PR opens next session", "green"),
|
|
52
|
+
(Stage.PR_OPEN, "PR OPEN", "pull request awaiting your review", "blue"),
|
|
53
|
+
(Stage.ACCEPTED, "ACCEPTED", "PR merged", "green"),
|
|
54
|
+
(Stage.REJECTED, "REJECTED", "PR closed", "red"),
|
|
55
|
+
(Stage.STALE, "STALE", "PR open too long", "bright_black"),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def stage_of(view: CandidateView) -> Stage:
|
|
60
|
+
"""Buckets a candidate: its status, with ``watching`` split into eligible vs not."""
|
|
61
|
+
match CandidateStatus(str(view.row["status"])):
|
|
62
|
+
case CandidateStatus.PR_OPEN:
|
|
63
|
+
return Stage.PR_OPEN
|
|
64
|
+
case CandidateStatus.ACCEPTED:
|
|
65
|
+
return Stage.ACCEPTED
|
|
66
|
+
case CandidateStatus.REJECTED:
|
|
67
|
+
return Stage.REJECTED
|
|
68
|
+
case CandidateStatus.STALE:
|
|
69
|
+
return Stage.STALE
|
|
70
|
+
case CandidateStatus.WATCHING:
|
|
71
|
+
return Stage.ELIGIBLE if view.eligible else Stage.WATCHING
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def trim(text: str, *, width: int = DETAIL_WIDTH) -> str:
|
|
75
|
+
return flat if len(flat := " ".join(text.split())) <= width else flat[: width - 1] + "…"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def bar(done: int, need: int, *, width: int = 5) -> str:
|
|
79
|
+
cells = min(need, width)
|
|
80
|
+
filled = min(cells, round(done / need * cells)) if need else cells
|
|
81
|
+
return BAR_FILLED * filled + BAR_EMPTY * (cells - filled)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def targets(view: CandidateView, settings: ReviewSettings) -> tuple[tuple[str, int, int], ...]:
|
|
85
|
+
t = view.threshold
|
|
86
|
+
match t.kind:
|
|
87
|
+
case CandidateKind.CREATE:
|
|
88
|
+
return (("sessions", t.sessions, settings.min_sessions), ("days", t.days, settings.min_days))
|
|
89
|
+
case CandidateKind.FIX:
|
|
90
|
+
return (("sessions", t.sessions, settings.min_sessions_fix), ("days", t.days, settings.min_days_fix))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def progress_text(view: CandidateView, settings: ReviewSettings) -> str:
|
|
94
|
+
return " ".join(
|
|
95
|
+
f"{label} {bar(done, need)} {done}/{need}" for label, done, need in targets(view, settings) if need > 0
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def pr_description(view: CandidateView) -> str:
|
|
100
|
+
"""The one-line summary of what this candidate's PR would (or did) do."""
|
|
101
|
+
row = view.row
|
|
102
|
+
detail = trim(view.summary or str(row["sample_text"] or ""))
|
|
103
|
+
match CandidateKind(str(row["candidate_kind"])):
|
|
104
|
+
case CandidateKind.CREATE:
|
|
105
|
+
return f'would add a hook: "{detail}"' if detail else "would add a hook for this correction"
|
|
106
|
+
case CandidateKind.FIX:
|
|
107
|
+
tail = view.summary or (
|
|
108
|
+
f"regression test for {row['misfire_class']}" if row["misfire_class"] else "regression test for the misfire"
|
|
109
|
+
)
|
|
110
|
+
return f"would fix {row['target_hook_name']} ({row['target_source_file']}): {trim(str(tail))}"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def age_days(row: dict[str, object]) -> int | None:
|
|
114
|
+
if not (opened := row["pr_opened_at"]):
|
|
115
|
+
return None
|
|
116
|
+
return (datetime.now(UTC) - datetime.fromisoformat(str(opened))).days
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def pr_link(view: CandidateView) -> str:
|
|
120
|
+
url = str(view.row["pr_url"] or "(no url)")
|
|
121
|
+
return f"{url} · {days}d open" if stage_of(view) is Stage.PR_OPEN and (days := age_days(view.row)) is not None else url
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def lead_detail(view: CandidateView, settings: ReviewSettings) -> str:
|
|
125
|
+
match stage_of(view):
|
|
126
|
+
case Stage.WATCHING:
|
|
127
|
+
return progress_text(view, settings)
|
|
128
|
+
case Stage.ELIGIBLE:
|
|
129
|
+
return f"ready · {progress_text(view, settings)}"
|
|
130
|
+
case _:
|
|
131
|
+
return pr_link(view)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def candidate_block(view: CandidateView, settings: ReviewSettings) -> RenderableType:
|
|
135
|
+
kind = CandidateKind(str(view.row["candidate_kind"]))
|
|
136
|
+
return Group(
|
|
137
|
+
Text.assemble(
|
|
138
|
+
(f" #{view.row['id']}", "bold"),
|
|
139
|
+
" ",
|
|
140
|
+
(kind.value.ljust(6), KIND_STYLE[kind]),
|
|
141
|
+
" ",
|
|
142
|
+
lead_detail(view, settings),
|
|
143
|
+
),
|
|
144
|
+
Text(f" {pr_description(view)}", style="dim"),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def header(repo: RepoKey, views: list[CandidateView], settings: ReviewSettings, *, watching: bool) -> RenderableType:
|
|
149
|
+
open_n = sum(1 for v in views if stage_of(v) is Stage.PR_OPEN)
|
|
150
|
+
line = Text.assemble(
|
|
151
|
+
("captain-hook", "bold"),
|
|
152
|
+
(" · ", "dim"),
|
|
153
|
+
(str(repo), "cyan"),
|
|
154
|
+
" ",
|
|
155
|
+
(f"[{'watching' if watching else 'not watching'}]", "green" if watching else "yellow"),
|
|
156
|
+
" ",
|
|
157
|
+
(f"PR slots {open_n}/{settings.max_open_prs}", "dim"),
|
|
158
|
+
)
|
|
159
|
+
if watching:
|
|
160
|
+
return line
|
|
161
|
+
return Group(line, Text(" run `capt-hook review enable` to start tracking this repo.", style="dim"))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def render(
|
|
165
|
+
views: list[CandidateView], *, repo: RepoKey, settings: ReviewSettings, watching: bool, syncing: bool = False
|
|
166
|
+
) -> RenderableType:
|
|
167
|
+
"""The whole dashboard frame: header, then a section per non-empty lifecycle stage."""
|
|
168
|
+
sections = [
|
|
169
|
+
block
|
|
170
|
+
for stage, title, desc, style in SECTIONS
|
|
171
|
+
if (members := [v for v in views if stage_of(v) is stage])
|
|
172
|
+
for block in (
|
|
173
|
+
Text.assemble((title, f"bold {style}"), (f" {desc}", "dim")),
|
|
174
|
+
*(candidate_block(v, settings) for v in members),
|
|
175
|
+
Text(""),
|
|
176
|
+
)
|
|
177
|
+
]
|
|
178
|
+
empty = [] if views else [Text("No corrections tracked yet — they appear here as you correct Claude.", style="dim")]
|
|
179
|
+
spinner = [Spinner("dots", text=Text("syncing open PRs with GitHub…", style="dim"))] if syncing else []
|
|
180
|
+
return Group(header(repo, views, settings, watching=watching), Text(""), *sections, *empty, *spinner)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
async def run_status(repo: RepoKey, *, sync: bool) -> None:
|
|
184
|
+
"""Renders the dashboard for ``repo``, refreshing open-PR state in the background when ``sync``."""
|
|
185
|
+
from rich.live import Live
|
|
186
|
+
|
|
187
|
+
from captain_hook.review.judge import REVIEW_PROMPT_VERSION
|
|
188
|
+
from captain_hook.review.settings import ReviewSettings
|
|
189
|
+
from captain_hook.review.store import ReviewStore
|
|
190
|
+
from captain_hook.review.sync import sync_open_prs
|
|
191
|
+
|
|
192
|
+
settings = ReviewSettings()
|
|
193
|
+
console = Console()
|
|
194
|
+
async with await ReviewStore.open(settings.db_path) as store:
|
|
195
|
+
watching = await store.watching(repo)
|
|
196
|
+
views = await store.overview(repo, settings=settings, prompt_version=REVIEW_PROMPT_VERSION)
|
|
197
|
+
if not (sync and any(stage_of(v) is Stage.PR_OPEN for v in views)):
|
|
198
|
+
console.print(render(views, repo=repo, settings=settings, watching=watching))
|
|
199
|
+
return
|
|
200
|
+
with Live(render(views, repo=repo, settings=settings, watching=watching, syncing=True), console=console) as live:
|
|
201
|
+
await sync_open_prs(store, repo, settings=settings)
|
|
202
|
+
fresh = await store.overview(repo, settings=settings, prompt_version=REVIEW_PROMPT_VERSION)
|
|
203
|
+
live.update(render(fresh, repo=repo, settings=settings, watching=watching))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def status_command(repo: RepoKey, *, sync: bool) -> None:
|
|
207
|
+
"""The synchronous CLI boundary for ``review status`` / ``capt-hook status``."""
|
|
208
|
+
asyncio.run(run_status(repo, sync=sync))
|
|
@@ -134,7 +134,7 @@ def brain_prompt(transcript: Path) -> str:
|
|
|
134
134
|
from captain_hook.review.scan import REVIEWER_MARKER
|
|
135
135
|
|
|
136
136
|
return (
|
|
137
|
-
f"/scanning-sessions --transcript {transcript}\n\n"
|
|
137
|
+
f"/captain-hook:scanning-sessions --transcript {transcript}\n\n"
|
|
138
138
|
f"[{REVIEWER_MARKER}] Review this repo's eligible candidates and open at most one pull request per"
|
|
139
139
|
" candidate. Work in one continuous run: do not stop to summarize after drafting — you are done only"
|
|
140
140
|
" when every eligible candidate has a PR recorded via `review update <id> pr_open --pr-url <url>` or"
|
|
@@ -143,13 +143,22 @@ def brain_prompt(transcript: Path) -> str:
|
|
|
143
143
|
|
|
144
144
|
|
|
145
145
|
def brain_argv(*, max_turns: int, max_budget_usd: float) -> list[str]:
|
|
146
|
+
from captain_hook.cli import plugin_dir
|
|
146
147
|
from captain_hook.llm import ClaudeBackend
|
|
147
148
|
|
|
148
149
|
backend = ClaudeBackend()
|
|
149
150
|
argv = backend.build_command(backend.models[BRAIN_TIER], None, agent=True)
|
|
150
151
|
argv[argv.index("--permission-mode") + 1] = "acceptEdits"
|
|
151
152
|
argv[argv.index("--max-budget-usd") + 1] = str(max_budget_usd)
|
|
152
|
-
return [
|
|
153
|
+
return [
|
|
154
|
+
*argv,
|
|
155
|
+
"--plugin-dir",
|
|
156
|
+
str(plugin_dir()),
|
|
157
|
+
"--max-turns",
|
|
158
|
+
str(max_turns),
|
|
159
|
+
"--allowedTools",
|
|
160
|
+
",".join(BRAIN_ALLOWED_TOOLS),
|
|
161
|
+
]
|
|
153
162
|
|
|
154
163
|
|
|
155
164
|
def spawn_brain(transcript: Path, *, repo_root: Path, settings: ReviewSettings) -> None:
|