capt-hook 3.3.1__tar.gz → 3.4.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.4.0/PKG-INFO +136 -0
- capt_hook-3.4.0/README.md +89 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/cli.py +12 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/review/cli.py +11 -0
- capt_hook-3.4.0/captain_hook/review/dashboard.py +208 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/review/store.py +112 -16
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/review/sync.py +5 -2
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +10 -13
- {capt_hook-3.3.1 → capt_hook-3.4.0}/pyproject.toml +2 -1
- capt_hook-3.3.1/PKG-INFO +0 -152
- capt_hook-3.3.1/README.md +0 -106
- {capt_hook-3.3.1 → capt_hook-3.4.0}/LICENSE +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/.claude-plugin/plugin.json +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/__init__.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/__main__.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/app.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/classifiers/__init__.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/classifiers/conductor.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/classifiers/droid.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/classifiers/native.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/command.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/conditions.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/context.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/decisions.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/dispatch.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/events.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/file.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/llm/__init__.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/loader.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/log.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/packs/__init__.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/packs/general/capt-hook.toml +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/packs/general/commands.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/packs/general/docs.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/packs/general/plans.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/packs/general/prompts.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/packs/general/review.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/packs/general/stewardship.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/packs/general/tasks.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/packs/manager.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/packs/python/capt-hook.toml +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/packs/python/style.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/packs/python/testing.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/packs/python/toolchain.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/primitives/__init__.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/primitives/commands.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/primitives/lint.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/primitives/llm.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/primitives/nudge.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/primitives/workflow.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/prompt.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/py.typed +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/review/__init__.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/review/fix.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/review/formats.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/review/judge.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/review/pipeline.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/review/repo.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/review/scan.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/review/settings.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/session.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/settings.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/signals/__init__.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/signals/nlp.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/skills/authoring-hooks/SKILL.md +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/skills/scanning-sessions/SKILL.md +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/state.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/style/__init__.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/style/matchers.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/style/scope.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/style/types.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/tasks.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/testing/__init__.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/testing/helpers.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/testing/session_cache.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/testing/types.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/tests/__init__.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/tests/helpers.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/types.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/util/__init__.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/util/model_cache.py +0 -0
- {capt_hook-3.3.1 → capt_hook-3.4.0}/captain_hook/utils.py +0 -0
capt_hook-3.4.0/PKG-INFO
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: capt-hook
|
|
3
|
+
Version: 3.4.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, installs the bundled skills, and arms the [session reviewer](#it-learns-from-your-corrections). Or install the plugin and let Claude do it. 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, installs the bundled skills, and arms the [session reviewer](#it-learns-from-your-corrections). Or install the plugin and let Claude do it. 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.
|
|
@@ -527,6 +527,18 @@ def logs(session: str | None, tail: int | None) -> None:
|
|
|
527
527
|
show_logs(session=session, tail=tail)
|
|
528
528
|
|
|
529
529
|
|
|
530
|
+
@cli.command()
|
|
531
|
+
@click.option("--repo", "repo_", default=None, help="Repo key (default: the current repo)")
|
|
532
|
+
@click.option("--sync/--no-sync", default=True, help="Refresh open PR states from GitHub in the background")
|
|
533
|
+
@click.pass_obj
|
|
534
|
+
def status(state: CliState, repo_: str | None, sync: bool) -> None:
|
|
535
|
+
"""Show the corrections the session reviewer is tracking and the hook PRs they would open."""
|
|
536
|
+
from captain_hook.review.cli import resolve_repo
|
|
537
|
+
from captain_hook.review.dashboard import status_command
|
|
538
|
+
|
|
539
|
+
status_command(resolve_repo(repo_, state.root), sync=sync)
|
|
540
|
+
|
|
541
|
+
|
|
530
542
|
@cli.group()
|
|
531
543
|
def skills() -> None:
|
|
532
544
|
"""Manage the bundled Claude Code skills."""
|
|
@@ -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))
|
|
@@ -113,6 +113,22 @@ SELECT c.*,
|
|
|
113
113
|
FROM candidates c
|
|
114
114
|
"""
|
|
115
115
|
|
|
116
|
+
PR_SUMMARY_QUERY = """
|
|
117
|
+
WITH latest AS (
|
|
118
|
+
SELECT v.dedup_key, v.{accepted} AS accepted, v.{summary} AS summary, v.confidence, ROW_NUMBER() OVER (
|
|
119
|
+
PARTITION BY v.dedup_key ORDER BY v.judged_at DESC, v.id DESC
|
|
120
|
+
) AS rn
|
|
121
|
+
FROM {table} v
|
|
122
|
+
WHERE v.role = 'judge' AND v.prompt_version = ?
|
|
123
|
+
)
|
|
124
|
+
SELECT l.summary AS summary
|
|
125
|
+
FROM candidate_observations o
|
|
126
|
+
JOIN latest l ON l.dedup_key = o.dedup_key AND l.rn = 1
|
|
127
|
+
WHERE o.candidate_id = ? AND l.accepted = 1 AND l.confidence >= ?
|
|
128
|
+
ORDER BY l.confidence DESC, o.id DESC
|
|
129
|
+
LIMIT 1
|
|
130
|
+
"""
|
|
131
|
+
|
|
116
132
|
|
|
117
133
|
class InvalidTransition(Exception):
|
|
118
134
|
"""Raised when a candidate status move is outside :data:`TRANSITIONS`."""
|
|
@@ -175,6 +191,47 @@ class ThresholdStatus:
|
|
|
175
191
|
single_observation: bool
|
|
176
192
|
|
|
177
193
|
|
|
194
|
+
def crosses_thresholds(status: ThresholdStatus, *, settings: ReviewSettings) -> bool:
|
|
195
|
+
"""Whether a candidate's judge-accepted evidence clears its kind's PR thresholds.
|
|
196
|
+
|
|
197
|
+
The single eligibility predicate, shared by :meth:`ReviewStore.eligible` and
|
|
198
|
+
the status dashboard so a candidate shown as eligible is exactly one the
|
|
199
|
+
reviewer would act on. Create candidates need ``min_sessions`` distinct
|
|
200
|
+
judge-accepted sessions across ``min_days`` distinct UTC days; fix candidates
|
|
201
|
+
need the ``min_sessions_fix``/``min_days_fix`` pair or one observation that is
|
|
202
|
+
both judge-accepted and heuristically at least ``min_confidence_fix_single``.
|
|
203
|
+
Both require the repo watched and a free slot under ``max_open_prs``.
|
|
204
|
+
"""
|
|
205
|
+
if status.status != CandidateStatus.WATCHING or not status.watching or status.open_prs >= settings.max_open_prs:
|
|
206
|
+
return False
|
|
207
|
+
match status.kind:
|
|
208
|
+
case CandidateKind.CREATE:
|
|
209
|
+
return status.sessions >= settings.min_sessions and status.days >= settings.min_days
|
|
210
|
+
case CandidateKind.FIX:
|
|
211
|
+
return (
|
|
212
|
+
status.sessions >= settings.min_sessions_fix and status.days >= settings.min_days_fix
|
|
213
|
+
) or status.single_observation
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@dataclass(frozen=True, slots=True)
|
|
217
|
+
class CandidateView:
|
|
218
|
+
"""One candidate's full dashboard record: its row, evidence counts, eligibility, and the PR it would open.
|
|
219
|
+
|
|
220
|
+
Attributes:
|
|
221
|
+
row: The :meth:`ReviewStore.candidates` row (status, kind, ``pr_url``,
|
|
222
|
+
``sample_text``, ``observations``, and the fix targets).
|
|
223
|
+
threshold: The judge-accepted evidence counts behind the eligibility call.
|
|
224
|
+
eligible: Whether :func:`crosses_thresholds` accepts ``threshold``.
|
|
225
|
+
summary: The highest-confidence accepted verdict's one-sentence summary —
|
|
226
|
+
what the candidate's PR would do — or ``None`` while still unjudged.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
row: dict[str, object]
|
|
230
|
+
threshold: ThresholdStatus
|
|
231
|
+
eligible: bool
|
|
232
|
+
summary: str | None
|
|
233
|
+
|
|
234
|
+
|
|
178
235
|
class ReviewStore(VerdictStoreMixin, FeedbackStore):
|
|
179
236
|
"""The session reviewer's persistent store over a :class:`FileStateStore`.
|
|
180
237
|
|
|
@@ -411,25 +468,64 @@ class ReviewStore(VerdictStoreMixin, FeedbackStore):
|
|
|
411
468
|
async def eligible(self, candidate_id: int, *, settings: ReviewSettings, prompt_version: int) -> bool:
|
|
412
469
|
"""Returns whether a candidate's judge-accepted evidence crosses its thresholds.
|
|
413
470
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
both judge-accepted and heuristically at least
|
|
418
|
-
``min_confidence_fix_single``. Both kinds require the repo watched and a
|
|
419
|
-
free slot under ``max_open_prs``.
|
|
471
|
+
Delegates to :func:`crosses_thresholds` over the candidate's
|
|
472
|
+
:meth:`threshold_status`, so the dashboard and the reviewer agree on what
|
|
473
|
+
is eligible.
|
|
420
474
|
|
|
421
475
|
Args:
|
|
422
476
|
candidate_id: The candidate to check.
|
|
423
477
|
settings: The thresholds and judge knobs to check under.
|
|
424
478
|
prompt_version: The judge prompt version whose verdicts apply.
|
|
425
479
|
"""
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
480
|
+
return crosses_thresholds(
|
|
481
|
+
await self.threshold_status(candidate_id, settings=settings, prompt_version=prompt_version),
|
|
482
|
+
settings=settings,
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
async def pr_summary(self, candidate_id: int, *, settings: ReviewSettings, prompt_version: int) -> str | None:
|
|
486
|
+
"""Returns the candidate's most-confident accepted verdict summary — what its PR would do.
|
|
487
|
+
|
|
488
|
+
Reads the same judge-accepted observations the thresholds count and
|
|
489
|
+
returns the highest-confidence verdict's one-sentence summary, or ``None``
|
|
490
|
+
while no observation is judged-accepted yet.
|
|
491
|
+
|
|
492
|
+
Args:
|
|
493
|
+
candidate_id: The candidate to describe.
|
|
494
|
+
settings: The judge knobs supplying ``min_judge_confidence``.
|
|
495
|
+
prompt_version: The judge prompt version whose verdicts apply.
|
|
496
|
+
"""
|
|
497
|
+
cur = await self.store.conn.execute(
|
|
498
|
+
PR_SUMMARY_QUERY.format(
|
|
499
|
+
table=self.VERDICT_TABLE, accepted=self.ACCEPTED_COLUMN, summary=self.SUMMARY_COLUMN
|
|
500
|
+
),
|
|
501
|
+
(prompt_version, candidate_id, settings.min_judge_confidence),
|
|
502
|
+
)
|
|
503
|
+
return str(rows[0]["summary"]) if (rows := [dict(row) async for row in cur]) else None
|
|
504
|
+
|
|
505
|
+
async def candidate_view(
|
|
506
|
+
self, row: dict[str, object], *, settings: ReviewSettings, prompt_version: int
|
|
507
|
+
) -> CandidateView:
|
|
508
|
+
"""Assembles one :class:`CandidateView` from a :meth:`candidates` row."""
|
|
509
|
+
candidate_id = int(str(row["id"]))
|
|
510
|
+
threshold = await self.threshold_status(candidate_id, settings=settings, prompt_version=prompt_version)
|
|
511
|
+
return CandidateView(
|
|
512
|
+
row=row,
|
|
513
|
+
threshold=threshold,
|
|
514
|
+
eligible=crosses_thresholds(threshold, settings=settings),
|
|
515
|
+
summary=await self.pr_summary(candidate_id, settings=settings, prompt_version=prompt_version),
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
async def overview(
|
|
519
|
+
self, repo: RepoKey | None = None, *, settings: ReviewSettings, prompt_version: int
|
|
520
|
+
) -> list[CandidateView]:
|
|
521
|
+
"""Returns a :class:`CandidateView` per candidate — the status dashboard's whole read.
|
|
522
|
+
|
|
523
|
+
Args:
|
|
524
|
+
repo: When set, restrict to this repo.
|
|
525
|
+
settings: The thresholds and judge knobs to evaluate under.
|
|
526
|
+
prompt_version: The judge prompt version whose verdicts apply.
|
|
527
|
+
"""
|
|
528
|
+
return [
|
|
529
|
+
await self.candidate_view(row, settings=settings, prompt_version=prompt_version)
|
|
530
|
+
for row in await self.candidates(repo)
|
|
531
|
+
]
|
|
@@ -8,6 +8,7 @@ skipped so the detached child never dies on it.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import asyncio
|
|
11
12
|
import json
|
|
12
13
|
import subprocess
|
|
13
14
|
from collections import Counter
|
|
@@ -75,9 +76,11 @@ async def sync_open_prs(store: ReviewStore, repo: RepoKey, *, settings: ReviewSe
|
|
|
75
76
|
The pass's transition counts.
|
|
76
77
|
"""
|
|
77
78
|
counts: Counter[str] = Counter()
|
|
78
|
-
|
|
79
|
+
rows = await store.candidates(repo, status=CandidateStatus.PR_OPEN)
|
|
80
|
+
states = await asyncio.gather(*(asyncio.to_thread(gh_pr_state, str(row["pr_url"])) for row in rows))
|
|
81
|
+
for row, state in zip(rows, states, strict=True):
|
|
79
82
|
candidate_id, url = int(str(row["id"])), str(row["pr_url"])
|
|
80
|
-
match
|
|
83
|
+
match state:
|
|
81
84
|
case "MERGED":
|
|
82
85
|
await store.transition(candidate_id, CandidateStatus.ACCEPTED)
|
|
83
86
|
counts["accepted"] += 1
|