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.
Files changed (93) hide show
  1. capt_hook-3.5.0/PKG-INFO +136 -0
  2. capt_hook-3.5.0/README.md +89 -0
  3. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/cli.py +35 -39
  4. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/review/cli.py +14 -3
  5. capt_hook-3.5.0/captain_hook/review/dashboard.py +208 -0
  6. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/review/pipeline.py +11 -2
  7. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/review/store.py +112 -16
  8. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/review/sync.py +5 -2
  9. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/skills/authoring-hooks/SKILL.md +1 -1
  10. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +1 -1
  11. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +2 -2
  12. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/skills/scanning-sessions/SKILL.md +3 -3
  13. {capt_hook-3.3.2 → capt_hook-3.5.0}/pyproject.toml +2 -1
  14. capt_hook-3.3.2/PKG-INFO +0 -152
  15. capt_hook-3.3.2/README.md +0 -106
  16. {capt_hook-3.3.2 → capt_hook-3.5.0}/LICENSE +0 -0
  17. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/.claude-plugin/plugin.json +0 -0
  18. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/__init__.py +0 -0
  19. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/__main__.py +0 -0
  20. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/app.py +0 -0
  21. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/classifiers/__init__.py +0 -0
  22. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/classifiers/conductor.py +0 -0
  23. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/classifiers/droid.py +0 -0
  24. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/classifiers/native.py +0 -0
  25. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/command.py +0 -0
  26. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/conditions.py +0 -0
  27. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/context.py +0 -0
  28. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/decisions.py +0 -0
  29. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/dispatch.py +0 -0
  30. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/events.py +0 -0
  31. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/file.py +0 -0
  32. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/llm/__init__.py +0 -0
  33. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/loader.py +0 -0
  34. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/log.py +0 -0
  35. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/packs/__init__.py +0 -0
  36. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/packs/general/capt-hook.toml +0 -0
  37. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/packs/general/commands.py +0 -0
  38. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/packs/general/docs.py +0 -0
  39. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/packs/general/plans.py +0 -0
  40. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/packs/general/prompts.py +0 -0
  41. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/packs/general/review.py +0 -0
  42. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/packs/general/stewardship.py +0 -0
  43. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/packs/general/tasks.py +0 -0
  44. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/packs/manager.py +0 -0
  45. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/packs/python/capt-hook.toml +0 -0
  46. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/packs/python/style.py +0 -0
  47. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/packs/python/testing.py +0 -0
  48. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/packs/python/toolchain.py +0 -0
  49. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/primitives/__init__.py +0 -0
  50. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/primitives/commands.py +0 -0
  51. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/primitives/lint.py +0 -0
  52. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/primitives/llm.py +0 -0
  53. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/primitives/nudge.py +0 -0
  54. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/primitives/workflow.py +0 -0
  55. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/prompt.py +0 -0
  56. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/py.typed +0 -0
  57. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/review/__init__.py +0 -0
  58. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/review/fix.py +0 -0
  59. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/review/formats.py +0 -0
  60. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/review/judge.py +0 -0
  61. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/review/repo.py +0 -0
  62. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/review/scan.py +0 -0
  63. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/review/settings.py +0 -0
  64. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/session.py +0 -0
  65. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/settings.py +0 -0
  66. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/signals/__init__.py +0 -0
  67. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/signals/nlp.py +0 -0
  68. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +0 -0
  69. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
  70. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +0 -0
  71. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
  72. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
  73. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
  74. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
  75. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
  76. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
  77. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/state.py +0 -0
  78. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/style/__init__.py +0 -0
  79. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/style/matchers.py +0 -0
  80. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/style/scope.py +0 -0
  81. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/style/types.py +0 -0
  82. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/tasks.py +0 -0
  83. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
  84. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/testing/__init__.py +0 -0
  85. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/testing/helpers.py +0 -0
  86. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/testing/session_cache.py +0 -0
  87. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/testing/types.py +0 -0
  88. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/tests/__init__.py +0 -0
  89. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/tests/helpers.py +0 -0
  90. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/types.py +0 -0
  91. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/util/__init__.py +0 -0
  92. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/util/model_cache.py +0 -0
  93. {capt_hook-3.3.2 → capt_hook-3.5.0}/captain_hook/utils.py +0 -0
@@ -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
+ ![captain-hook banner](https://github.com/yasyf/captain-hook/raw/main/docs/assets/readme-banner.webp)
51
+
52
+ [![PyPI](https://img.shields.io/pypi/v/capt-hook.svg)](https://pypi.org/project/capt-hook/)
53
+ [![Python](https://img.shields.io/pypi/pyversions/capt-hook.svg)](https://pypi.org/project/capt-hook/)
54
+ [![Docs](https://github.com/yasyf/captain-hook/actions/workflows/docs.yml/badge.svg)](https://yasyf.github.io/captain-hook/)
55
+ [![License: PolyForm Noncommercial](https://img.shields.io/badge/License-PolyForm_Noncommercial_1.0.0-blue.svg)](https://github.com/yasyf/captain-hook/blob/main/LICENSE)
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
+ ![captain-hook banner](https://github.com/yasyf/captain-hook/raw/main/docs/assets/readme-banner.webp)
4
+
5
+ [![PyPI](https://img.shields.io/pypi/v/capt-hook.svg)](https://pypi.org/project/capt-hook/)
6
+ [![Python](https://img.shields.io/pypi/pyversions/capt-hook.svg)](https://pypi.org/project/capt-hook/)
7
+ [![Docs](https://github.com/yasyf/captain-hook/actions/workflows/docs.yml/badge.svg)](https://yasyf.github.io/captain-hook/)
8
+ [![License: PolyForm Noncommercial](https://img.shields.io/badge/License-PolyForm_Noncommercial_1.0.0-blue.svg)](https://github.com/yasyf/captain-hook/blob/main/LICENSE)
9
+
10
+ Guardrails for Claude Code, written as typed, testable data — and learned from the corrections you give Claude.
11
+
12
+ A captain-hook hook is declarative Python: an event, some conditions, an action. Block a footgun before it runs, nudge the agent off a bad pattern, gate "done" until the tests pass. Then captain-hook closes the loop: it reads the corrections you give Claude as you work and opens pull requests that codify the durable ones as new hooks. You write the first few; it writes the rest.
13
+
14
+ ## Install
15
+
16
+ captain-hook needs no install — it runs through [uvx](https://docs.astral.sh/uv/). From your project root:
17
+
18
+ ```bash
19
+ uvx capt-hook init
20
+ ```
21
+
22
+ `init` scaffolds `.claude/hooks/`, wires Claude Code's settings, registers the captain-hook plugin so its skills install on workspace-trust, and arms the [session reviewer](#it-learns-from-your-corrections). Or do it all from a session. Run `/plugin marketplace add yasyf/captain-hook`, then ask Claude to "set up captain hook".
23
+
24
+ ## Your first hook
25
+
26
+ A hook is an event, some conditions, and an action. This one stops the agent from finishing a UI change it never looked at:
27
+
28
+ ```python
29
+ # .claude/hooks/visual_review.py
30
+ from captain_hook import gate, TouchedFile, UsedSkill
31
+
32
+ gate(
33
+ "You edited UI files. Open them with agent-browser and verify they render before finishing.",
34
+ only_if=[TouchedFile("**/src/routes/**", "**/src/components/**")],
35
+ skip_if=[UsedSkill("agent-browser")],
36
+ )
37
+ ```
38
+
39
+ `only_if` arms the gate only when UI files changed; `skip_if` stands it down once the agent has done the review. Conditions match tools, files, commands, and even which skills the agent used.
40
+
41
+ ## It learns from your corrections
42
+
43
+ Most hooks you'll never write by hand.
44
+
45
+ The corrections you give Claude as you work are exactly the rules a hook should enforce: "never force-push", "use `uv`, not `pip`", "you weakened that test". Writing the hook by hand is friction you skip in the moment, so the **session reviewer** notices for you. When a session ends, it reads the transcript, finds the durable corrections and the hooks that misfired, judges which ones are standing rules and which are one-offs, and once a pattern proves itself across sessions, opens a pull request that adds the hook — or fixes the one that misfired. You review the PR like any other.
46
+
47
+ It's on by default after `init`. Turn it off for a repo with `uvx capt-hook review disable`. The [session reviewer guide](https://yasyf.github.io/captain-hook/docs/guide/session-reviewer.html) covers the prerequisites (an authenticated `claude` and `gh`) and the `HOOKS_REVIEW_*` thresholds.
48
+
49
+ ## Tested like code
50
+
51
+ Every deterministic hook carries inline tests, so a broken hook fails like broken code:
52
+
53
+ ```python
54
+ # .claude/hooks/safety.py
55
+ from captain_hook import Allow, Block, Input, block_command
56
+
57
+ block_command(
58
+ ["git", "stash"],
59
+ reason="Use the team's VCS workflow for shelving changes",
60
+ hint="Commit a WIP change instead of stashing",
61
+ tests={
62
+ Input(command="git stash"): Block(),
63
+ Input(command="git status"): Allow(),
64
+ },
65
+ )
66
+ ```
67
+
68
+ Run them from your project root, where `--hooks` defaults to `.claude/hooks`:
69
+
70
+ ```bash
71
+ uvx capt-hook test
72
+ ```
73
+
74
+ Wire that into CI and you catch a broken hook the way you catch broken code.
75
+
76
+ ## What it's for
77
+
78
+ - Block footguns before they run on `PreToolUse`: force-push, `rm -rf`, package-manager traps.
79
+ - Steer the agent with feedback that fires on the patterns it actually emits: repeated failures, weakened tests, missed conventions.
80
+ - Hold the line on multi-step work with Stop gates and artifact checks, so the agent can't call it "done" before the tests run or the report's written.
81
+ - Keep all of it testable; every hook ships with inline tests that run in CI.
82
+
83
+ ## Docs
84
+
85
+ [Read the docs](https://yasyf.github.io/captain-hook/) for the full guide to conditions, primitives, LLM hooks, workflows, state, and real-world patterns. To work on captain-hook itself, see the [development guide](https://yasyf.github.io/captain-hook/docs/development/).
86
+
87
+ ## License
88
+
89
+ Licensed under [PolyForm Noncommercial 1.0.0](LICENSE), free for noncommercial use.
@@ -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 install_skills(root: Path, *, force: bool = False) -> dict[str, str]:
58
- """Copy the bundled Claude Code skills into ``root/.claude/skills``.
57
+ def plugin_dir() -> Path:
58
+ """Filesystem path to the bundled captain-hook plugin root.
59
59
 
60
- Args:
61
- root: Project root receiving the skills.
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
- dest_root = root / ".claude" / "skills"
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.local.json``.
67
+ """Enable the captain-hook plugin marketplace in ``root/.claude/settings.json``.
86
68
 
87
69
  Merges ``extraKnownMarketplaces`` and ``enabledPlugins`` entries into the
88
- existing settings so the bundled skills track the repository as a plugin.
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.local.json"
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.local.json`` before launching.
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(["claude", "/bootstrapping-hooks"], cwd=root, check=False)
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
- skills_summary = install_skills(root)
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(".claude/skills/:")
336
- for name in (n for n, status in skills_summary.items() if status == "installed"):
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, force: bool) -> None:
539
- """Copy the bundled skills into .claude/skills/."""
540
- for name, status in install_skills(state.root, force=force).items():
541
- click.echo(f" {status} {name}")
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, install the reviewer's skills, and wire the SessionEnd hook."""
124
- from captain_hook.cli import install_skills
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
- install_skills(state.root)
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 [*argv, "--max-turns", str(max_turns), "--allowedTools", ",".join(BRAIN_ALLOWED_TOOLS)]
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: