capt-hook 3.6.0__tar.gz → 3.8.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.6.0 → capt_hook-3.8.0}/PKG-INFO +3 -3
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/cli.py +16 -12
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/packs/general/docs.py +3 -1
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/review/cli.py +41 -9
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/review/scan.py +35 -1
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/review/store.py +29 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/skills/authoring-hooks/SKILL.md +2 -2
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +1 -1
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +1 -1
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +7 -7
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +1 -1
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/skills/translating-styleguides/SKILL.md +1 -1
- {capt_hook-3.6.0 → capt_hook-3.8.0}/pyproject.toml +3 -3
- {capt_hook-3.6.0 → capt_hook-3.8.0}/LICENSE +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/README.md +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/.claude-plugin/plugin.json +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/__init__.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/__main__.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/app.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/classifiers/__init__.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/classifiers/conductor.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/classifiers/droid.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/classifiers/native.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/command.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/conditions.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/context.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/decisions.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/dispatch.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/events.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/file.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/llm/__init__.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/loader.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/log.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/packs/__init__.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/packs/general/capt-hook.toml +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/packs/general/commands.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/packs/general/plans.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/packs/general/prompts.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/packs/general/review.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/packs/general/stewardship.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/packs/general/tasks.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/packs/manager.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/packs/python/capt-hook.toml +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/packs/python/style.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/packs/python/testing.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/packs/python/toolchain.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/primitives/__init__.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/primitives/commands.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/primitives/lint.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/primitives/llm.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/primitives/nudge.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/primitives/workflow.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/prompt.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/py.typed +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/review/__init__.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/review/dashboard.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/review/fix.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/review/formats.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/review/judge.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/review/pipeline.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/review/repo.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/review/settings.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/review/sync.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/session.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/settings.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/signals/__init__.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/signals/nlp.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/skills/scanning-sessions/SKILL.md +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/state.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/style/__init__.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/style/matchers.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/style/scope.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/style/types.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/tasks.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/testing/__init__.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/testing/helpers.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/testing/session_cache.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/testing/types.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/tests/__init__.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/tests/helpers.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/types.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/util/__init__.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/util/model_cache.py +0 -0
- {capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: capt-hook
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.8.0
|
|
4
4
|
Summary: Declarative hook framework for Claude Code
|
|
5
5
|
Keywords: claude,claude-code,hooks,llm,agents,guardrails,cli
|
|
6
6
|
Author: Yasyf Mohamedali
|
|
@@ -16,7 +16,7 @@ Classifier: Programming Language :: Python :: 3 :: Only
|
|
|
16
16
|
Classifier: Topic :: Software Development :: Quality Assurance
|
|
17
17
|
Classifier: Topic :: Software Development :: Testing
|
|
18
18
|
Classifier: Typing :: Typed
|
|
19
|
-
Requires-Dist: cc-transcript>=
|
|
19
|
+
Requires-Dist: cc-transcript>=4,<5
|
|
20
20
|
Requires-Dist: pydantic>=2.0
|
|
21
21
|
Requires-Dist: pydantic-settings>=2.0
|
|
22
22
|
Requires-Dist: tree-sitter>=0.24
|
|
@@ -30,7 +30,7 @@ Requires-Dist: wn>=1.1.0
|
|
|
30
30
|
Requires-Dist: lazy-object-proxy>=1.12.0
|
|
31
31
|
Requires-Dist: filelock>=3
|
|
32
32
|
Requires-Dist: loguru>=0.7.3
|
|
33
|
-
Requires-Dist: spawnllm>=0.
|
|
33
|
+
Requires-Dist: spawnllm>=0.2.0
|
|
34
34
|
Requires-Dist: pytest>=8.0 ; extra == 'dev'
|
|
35
35
|
Requires-Dist: pytest-asyncio>=0.24 ; extra == 'dev'
|
|
36
36
|
Requires-Dist: pyright>=1.1 ; extra == 'dev'
|
|
@@ -155,21 +155,25 @@ def capt_hook_events(path: Path) -> set[str]:
|
|
|
155
155
|
}
|
|
156
156
|
|
|
157
157
|
|
|
158
|
+
def sibling_settings(path: Path) -> Path:
|
|
159
|
+
return path.parent / ("settings.json" if path.name == "settings.local.json" else "settings.local.json")
|
|
160
|
+
|
|
161
|
+
|
|
158
162
|
def merge_settings(
|
|
159
163
|
hooks_dir: str, settings_path: Path, from_source: str = DIST_NAME
|
|
160
164
|
) -> tuple[dict[str, Any], dict[str, str]]:
|
|
161
165
|
new_hooks: dict[str, list[dict[str, Any]]] = generate_settings(hooks_dir, from_source=from_source)["hooks"]
|
|
162
166
|
existing = json.loads(settings_path.read_text()) if settings_path.exists() else {}
|
|
163
167
|
existing_hooks: dict[str, list[dict[str, Any]]] = existing.get("hooks") or {}
|
|
164
|
-
|
|
168
|
+
deferred = capt_hook_events(sibling_settings(settings_path))
|
|
165
169
|
|
|
166
170
|
summary: dict[str, str] = {}
|
|
167
171
|
merged_hooks: dict[str, list[dict[str, Any]]] = {}
|
|
168
172
|
for event in sorted(existing_hooks.keys() | new_hooks.keys()):
|
|
169
173
|
foreign = [g for g in existing_hooks.get(event, []) if not is_captain_hook_group(g)]
|
|
170
174
|
old_own = [g for g in existing_hooks.get(event, []) if is_captain_hook_group(g)]
|
|
171
|
-
fresh_own = [] if event in
|
|
172
|
-
if event in
|
|
175
|
+
fresh_own = [] if event in deferred else new_hooks.get(event, [])
|
|
176
|
+
if event in deferred and (old_own or new_hooks.get(event)):
|
|
173
177
|
summary[event] = "deferred"
|
|
174
178
|
elif old_own or fresh_own:
|
|
175
179
|
summary[event] = (
|
|
@@ -193,7 +197,7 @@ def write_settings(settings_path: Path, data: dict[str, Any]) -> None:
|
|
|
193
197
|
os.replace(tmp, settings_path)
|
|
194
198
|
|
|
195
199
|
|
|
196
|
-
def print_hook_summary(label: str, summary: dict[str, str]) -> None:
|
|
200
|
+
def print_hook_summary(label: str, summary: dict[str, str], deferred_to: str) -> None:
|
|
197
201
|
by_status: defaultdict[str, list[str]] = defaultdict(list)
|
|
198
202
|
for event, status in summary.items():
|
|
199
203
|
by_status[status].append(event)
|
|
@@ -209,15 +213,15 @@ def print_hook_summary(label: str, summary: dict[str, str]) -> None:
|
|
|
209
213
|
if unchanged := by_status["unchanged"]:
|
|
210
214
|
click.echo(f" unchanged: {', '.join(unchanged)} (already present)")
|
|
211
215
|
if deferred := by_status["deferred"]:
|
|
212
|
-
click.echo(f" deferred to
|
|
216
|
+
click.echo(f" deferred to {deferred_to}: {', '.join(deferred)}")
|
|
213
217
|
|
|
214
218
|
|
|
215
219
|
def regenerate_settings(state: CliState) -> None:
|
|
216
220
|
state.discover()
|
|
217
|
-
settings_path = state.root / ".claude" / "settings.
|
|
221
|
+
settings_path = state.root / ".claude" / "settings.json"
|
|
218
222
|
merged, summary = merge_settings(".claude/hooks", settings_path)
|
|
219
223
|
write_settings(settings_path, merged)
|
|
220
|
-
print_hook_summary(str(settings_path.relative_to(state.root)), summary)
|
|
224
|
+
print_hook_summary(str(settings_path.relative_to(state.root)), summary, sibling_settings(settings_path).name)
|
|
221
225
|
|
|
222
226
|
|
|
223
227
|
def settings_drift(root: Path) -> set[str]:
|
|
@@ -309,7 +313,7 @@ def init_project(root: Path, *, review: bool = True) -> None:
|
|
|
309
313
|
if not example.exists():
|
|
310
314
|
example.write_text(example_hook_source())
|
|
311
315
|
|
|
312
|
-
settings_path = root / ".claude" / "settings.
|
|
316
|
+
settings_path = root / ".claude" / "settings.json"
|
|
313
317
|
CliState(root=root, hooks=str(hooks_dir)).discover()
|
|
314
318
|
merged, summary = merge_settings(".claude/hooks", settings_path)
|
|
315
319
|
write_settings(settings_path, merged)
|
|
@@ -318,7 +322,7 @@ def init_project(root: Path, *, review: bool = True) -> None:
|
|
|
318
322
|
|
|
319
323
|
click.echo(f"Scaffolded {example.relative_to(root)} + {settings_path.relative_to(root)}.")
|
|
320
324
|
click.echo()
|
|
321
|
-
print_hook_summary(str(settings_path.relative_to(root)), summary)
|
|
325
|
+
print_hook_summary(str(settings_path.relative_to(root)), summary, sibling_settings(settings_path).name)
|
|
322
326
|
click.echo()
|
|
323
327
|
click.echo("Claude Code plugin:")
|
|
324
328
|
click.echo(f" + registered {PLUGIN_ID} in .claude/settings.json (skills install on folder-trust)")
|
|
@@ -476,15 +480,15 @@ def run(state: CliState, event: str, async_: bool) -> None:
|
|
|
476
480
|
)
|
|
477
481
|
@click.pass_obj
|
|
478
482
|
def register_hooks_cmd(state: CliState, hooks_dir: str, dry_run: bool, from_source: str) -> None:
|
|
479
|
-
"""Register captain-hook's event hooks into .claude/settings.
|
|
483
|
+
"""Register captain-hook's event hooks into .claude/settings.json."""
|
|
480
484
|
state.discover()
|
|
481
|
-
settings_path = state.root / ".claude" / "settings.
|
|
485
|
+
settings_path = state.root / ".claude" / "settings.json"
|
|
482
486
|
merged, summary = merge_settings(hooks_dir, settings_path, from_source=from_source)
|
|
483
487
|
if dry_run:
|
|
484
488
|
click.echo(json.dumps(merged, indent=2))
|
|
485
489
|
return
|
|
486
490
|
write_settings(settings_path, merged)
|
|
487
|
-
print_hook_summary(str(settings_path), summary)
|
|
491
|
+
print_hook_summary(str(settings_path), summary, sibling_settings(settings_path).name)
|
|
488
492
|
|
|
489
493
|
|
|
490
494
|
@cli.command()
|
|
@@ -12,7 +12,9 @@ from captain_hook import Allow, FilePath, Input, Tool, UsedSkill, Warn, nudge
|
|
|
12
12
|
nudge(
|
|
13
13
|
"You're editing documentation. Consult the writing-docs skill first for the "
|
|
14
14
|
"Diataxis modes, voice rules, and code-sample rules, then run "
|
|
15
|
-
"`slop-cop check <file> --lang=markdown` to catch prose tells before you finish."
|
|
15
|
+
"`slop-cop check <file> --lang=markdown` to catch prose tells before you finish. "
|
|
16
|
+
"slop-cop is a Go binary — if it's not on PATH, run the `/slop-cop-check` skill "
|
|
17
|
+
"(it installs it), never `uvx slop-cop`.",
|
|
16
18
|
only_if=[Tool("Write|Edit"), FilePath("**/*.md", "**/*.qmd", "docs/**", "README.md")],
|
|
17
19
|
skip_if=[UsedSkill("writing-docs|writing-docs:writing-docs")],
|
|
18
20
|
max_fires=1,
|
|
@@ -21,6 +21,8 @@ import click
|
|
|
21
21
|
if TYPE_CHECKING:
|
|
22
22
|
from typing import Any
|
|
23
23
|
|
|
24
|
+
from cc_transcript.corrections import Correction
|
|
25
|
+
|
|
24
26
|
from captain_hook.cli import CliState
|
|
25
27
|
from captain_hook.review.judge import JudgeReport
|
|
26
28
|
from captain_hook.review.repo import RepoKey
|
|
@@ -57,13 +59,13 @@ def review_wired(hooks: dict[str, Any]) -> bool:
|
|
|
57
59
|
|
|
58
60
|
|
|
59
61
|
def ensure_review_wiring(settings_path: Path) -> bool:
|
|
60
|
-
from captain_hook.cli import write_settings
|
|
62
|
+
from captain_hook.cli import sibling_settings, write_settings
|
|
61
63
|
|
|
62
64
|
existing: dict[str, Any] = json.loads(settings_path.read_text()) if settings_path.exists() else {}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
+
sibling = sibling_settings(settings_path)
|
|
66
|
+
sibling_hooks: dict[str, Any] = (json.loads(sibling.read_text()).get("hooks") or {}) if sibling.exists() else {}
|
|
65
67
|
hooks: dict[str, Any] = existing.get("hooks") or {}
|
|
66
|
-
if review_wired(hooks) or review_wired(
|
|
68
|
+
if review_wired(hooks) or review_wired(sibling_hooks):
|
|
67
69
|
return False
|
|
68
70
|
group = {"hooks": [{"type": "command", "command": f"uvx {REVIEW_RUN_COMMAND}"}]}
|
|
69
71
|
write_settings(
|
|
@@ -126,8 +128,8 @@ def enable(state: CliState) -> None:
|
|
|
126
128
|
repo = current_repo(state.root)
|
|
127
129
|
watch_repo(repo)
|
|
128
130
|
register_marketplace(state.root)
|
|
129
|
-
wired = ensure_review_wiring(state.root / ".claude" / "settings.
|
|
130
|
-
click.echo(f"watching {repo}" + (" (SessionEnd hook wired into .claude/settings.
|
|
131
|
+
wired = ensure_review_wiring(state.root / ".claude" / "settings.json")
|
|
132
|
+
click.echo(f"watching {repo}" + (" (SessionEnd hook wired into .claude/settings.json)" if wired else ""))
|
|
131
133
|
|
|
132
134
|
|
|
133
135
|
@review.command()
|
|
@@ -229,26 +231,52 @@ def list_candidates(state: CliState, repo_: str | None) -> None:
|
|
|
229
231
|
click.echo(candidate_line(row))
|
|
230
232
|
|
|
231
233
|
|
|
234
|
+
def correction_lines(correction: Correction) -> tuple[str, ...]:
|
|
235
|
+
match correction.correction_origin:
|
|
236
|
+
case "session" | "git":
|
|
237
|
+
return (
|
|
238
|
+
f" correction ({correction.correction_origin}):",
|
|
239
|
+
f" - {correction.correction_old}",
|
|
240
|
+
f" + {correction.correction_new}",
|
|
241
|
+
)
|
|
242
|
+
case _ if correction.correction_text:
|
|
243
|
+
return (f" correction note: {correction.correction_text}",)
|
|
244
|
+
case _:
|
|
245
|
+
return ()
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def correction_block(correction: Correction) -> str:
|
|
249
|
+
return "\n".join(
|
|
250
|
+
(
|
|
251
|
+
f"- {correction.incorrect_file} (session {correction.session_id}):",
|
|
252
|
+
f" - {correction.incorrect_old}",
|
|
253
|
+
f" + {correction.incorrect_new}",
|
|
254
|
+
*correction_lines(correction),
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
232
259
|
@review.command()
|
|
233
260
|
@click.argument("candidate_id", type=int)
|
|
234
261
|
def show(candidate_id: int) -> None:
|
|
235
|
-
"""Show one candidate's row
|
|
262
|
+
"""Show one candidate's row, its threshold status, and the shared ledger's faulted edits."""
|
|
236
263
|
from captain_hook.review.judge import REVIEW_PROMPT_VERSION
|
|
237
264
|
from captain_hook.review.settings import ReviewSettings
|
|
238
265
|
from captain_hook.review.store import ReviewStore
|
|
239
266
|
|
|
240
267
|
settings = ReviewSettings()
|
|
241
268
|
|
|
242
|
-
async def go() -> tuple[dict[str, object], ThresholdStatus, bool]:
|
|
269
|
+
async def go() -> tuple[dict[str, object], ThresholdStatus, bool, tuple[Correction, ...]]:
|
|
243
270
|
async with await ReviewStore.open(settings.db_path) as store:
|
|
244
271
|
return (
|
|
245
272
|
await store.candidate(candidate_id),
|
|
246
273
|
await store.threshold_status(candidate_id, settings=settings, prompt_version=REVIEW_PROMPT_VERSION),
|
|
247
274
|
await store.eligible(candidate_id, settings=settings, prompt_version=REVIEW_PROMPT_VERSION),
|
|
275
|
+
await store.correction_evidence(candidate_id),
|
|
248
276
|
)
|
|
249
277
|
|
|
250
278
|
try:
|
|
251
|
-
row, status, ok = asyncio.run(go())
|
|
279
|
+
row, status, ok, evidence = asyncio.run(go())
|
|
252
280
|
except LookupError as exc:
|
|
253
281
|
raise click.ClickException(str(exc)) from exc
|
|
254
282
|
for key, value in row.items():
|
|
@@ -257,6 +285,10 @@ def show(candidate_id: int) -> None:
|
|
|
257
285
|
f"thresholds: sessions={status.sessions} days={status.days} open_prs={status.open_prs} "
|
|
258
286
|
f"single_observation={status.single_observation} eligible={ok}"
|
|
259
287
|
)
|
|
288
|
+
if evidence:
|
|
289
|
+
click.echo("correction_evidence:")
|
|
290
|
+
for correction in evidence:
|
|
291
|
+
click.echo(correction_block(correction))
|
|
260
292
|
|
|
261
293
|
|
|
262
294
|
@review.command(name="threshold-check")
|
|
@@ -30,6 +30,7 @@ from __future__ import annotations
|
|
|
30
30
|
|
|
31
31
|
from dataclasses import dataclass
|
|
32
32
|
from itertools import chain
|
|
33
|
+
from pathlib import Path
|
|
33
34
|
from typing import TYPE_CHECKING
|
|
34
35
|
|
|
35
36
|
from cc_transcript.activity import SessionActivity
|
|
@@ -76,7 +77,6 @@ from captain_hook.review.store import CandidateKind
|
|
|
76
77
|
|
|
77
78
|
if TYPE_CHECKING:
|
|
78
79
|
from collections.abc import Iterable, Iterator, Mapping, Sequence
|
|
79
|
-
from pathlib import Path
|
|
80
80
|
from typing import Any
|
|
81
81
|
|
|
82
82
|
from cc_transcript.backend import ParsedTranscript
|
|
@@ -243,6 +243,39 @@ def transcript_repo(events: Sequence[TranscriptEvent]) -> RepoKey | None:
|
|
|
243
243
|
)
|
|
244
244
|
|
|
245
245
|
|
|
246
|
+
def transcript_cwd(events: Sequence[TranscriptEvent]) -> Path | None:
|
|
247
|
+
return next(
|
|
248
|
+
(Path(meta.cwd) for event in events if (meta := event_meta(event)) is not None if meta.cwd is not None),
|
|
249
|
+
None,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
async def record_corrections(
|
|
254
|
+
events: Sequence[TranscriptEvent], kept: Sequence[tuple[MiningSignal, FeedbackCandidate]], *, repo: Path | None
|
|
255
|
+
) -> None:
|
|
256
|
+
"""Grounds each user-correction candidate in the shared code-correction ledger.
|
|
257
|
+
|
|
258
|
+
For every kept user-correction signal (the FIX-mode ``hook_complaint`` is a
|
|
259
|
+
local hook misfire, not a code correction, so it is skipped), harvests the
|
|
260
|
+
edit the feedback faults around its anchor and appends one row to the family
|
|
261
|
+
ledger. Idempotent per anchor: a no-op when cc-pushback already wrote it, so
|
|
262
|
+
captain-hook only fills the ledger for sessions nobody else processed.
|
|
263
|
+
"""
|
|
264
|
+
from cc_transcript.corrections import CorrectionLog
|
|
265
|
+
from cc_transcript.extract import extract_correction, usable_backend
|
|
266
|
+
|
|
267
|
+
corrections = [(sig, candidate) for sig, candidate in kept if sig.kind != HOOK_COMPLAINT]
|
|
268
|
+
if not corrections:
|
|
269
|
+
return
|
|
270
|
+
activity = SessionActivity.from_events(corrections[0][0].session_id, events)
|
|
271
|
+
backend = usable_backend()
|
|
272
|
+
log = CorrectionLog.open()
|
|
273
|
+
for sig, candidate in corrections:
|
|
274
|
+
await extract_correction(
|
|
275
|
+
log, activity, candidate.ref, source="captain-hook", feedback=sig.text, repo=repo, backend=backend
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
246
279
|
async def ingest(
|
|
247
280
|
store: ReviewStore, parsed: ParsedTranscript, *, settings: ReviewSettings, repo_key: RepoKey | None
|
|
248
281
|
) -> ScanReport:
|
|
@@ -275,6 +308,7 @@ async def ingest(
|
|
|
275
308
|
await store.record_observation(
|
|
276
309
|
candidate_id, dedup_key=candidate.dedup_key, session_id=sig.session_id, occurred_at=sig.occurred_at
|
|
277
310
|
)
|
|
311
|
+
await record_corrections(parsed.events, kept, repo=transcript_cwd(parsed.events))
|
|
278
312
|
return ScanReport(scanned=1, inserted=inserted)
|
|
279
313
|
|
|
280
314
|
|
|
@@ -28,6 +28,7 @@ if TYPE_CHECKING:
|
|
|
28
28
|
from pathlib import Path
|
|
29
29
|
from typing import Any
|
|
30
30
|
|
|
31
|
+
from cc_transcript.corrections import Correction
|
|
31
32
|
from cc_transcript.ids import SessionId
|
|
32
33
|
from cc_transcript.mining.candidates import DedupKey
|
|
33
34
|
from cc_transcript.mining.confidence import Confidence
|
|
@@ -105,6 +106,14 @@ WHERE o.candidate_id = ? AND l.accepted = 1 AND l.confidence >= ?
|
|
|
105
106
|
|
|
106
107
|
OPEN_PRS_QUERY = "SELECT COUNT(*) AS n FROM candidates WHERE repo_key = ? AND status = ? AND pr_opened_at > ?"
|
|
107
108
|
|
|
109
|
+
OBSERVATION_ANCHORS_QUERY = """
|
|
110
|
+
SELECT DISTINCT e.session_id, e.event_uuid
|
|
111
|
+
FROM candidate_observations o
|
|
112
|
+
JOIN feedback_events e ON e.dedup_key = o.dedup_key
|
|
113
|
+
WHERE o.candidate_id = ? AND e.session_id IS NOT NULL AND e.event_uuid IS NOT NULL
|
|
114
|
+
ORDER BY o.id
|
|
115
|
+
"""
|
|
116
|
+
|
|
108
117
|
CANDIDATES_QUERY = """
|
|
109
118
|
SELECT c.*,
|
|
110
119
|
(SELECT e.text FROM candidate_observations o JOIN feedback_events e ON e.dedup_key = o.dedup_key
|
|
@@ -502,6 +511,26 @@ class ReviewStore(VerdictStoreMixin, FeedbackStore):
|
|
|
502
511
|
)
|
|
503
512
|
return str(rows[0]["summary"]) if (rows := [dict(row) async for row in cur]) else None
|
|
504
513
|
|
|
514
|
+
async def correction_evidence(self, candidate_id: int) -> tuple[Correction, ...]:
|
|
515
|
+
"""Returns the shared-ledger code corrections grounding a candidate's observations.
|
|
516
|
+
|
|
517
|
+
Joins each observation back to its feedback anchor ``(session_id,
|
|
518
|
+
event_uuid)`` and pulls the corrections the family ledger holds for that
|
|
519
|
+
anchor — the offending before/after edit the PR-drafting brain needs. The
|
|
520
|
+
reviewer's own per-session pass writes these rows, so a candidate that
|
|
521
|
+
crossed its thresholds carries its faulted edits.
|
|
522
|
+
"""
|
|
523
|
+
from cc_transcript.corrections import CorrectionLog
|
|
524
|
+
from cc_transcript.ids import EventUuid, SessionId
|
|
525
|
+
|
|
526
|
+
cur = await self.store.conn.execute(OBSERVATION_ANCHORS_QUERY, (candidate_id,))
|
|
527
|
+
log = CorrectionLog.open()
|
|
528
|
+
return tuple(
|
|
529
|
+
correction
|
|
530
|
+
for row in [dict(row) async for row in cur]
|
|
531
|
+
for correction in log.for_anchor(SessionId(str(row["session_id"])), EventUuid(str(row["event_uuid"])))
|
|
532
|
+
)
|
|
533
|
+
|
|
505
534
|
async def candidate_view(
|
|
506
535
|
self, row: dict[str, object], *, settings: ReviewSettings, prompt_version: int
|
|
507
536
|
) -> CandidateView:
|
|
@@ -9,7 +9,7 @@ allowed-tools: Read, Grep, Glob, Write, Edit, Bash(uvx capt-hook:*, capt-hook:*,
|
|
|
9
9
|
|
|
10
10
|
capt-hook is a declarative hook framework for Claude Code. Hooks are Python files in
|
|
11
11
|
`.claude/hooks/`, dispatched by `uvx capt-hook run <Event>` entries in
|
|
12
|
-
`.claude/settings.
|
|
12
|
+
`.claude/settings.json`. Each hook carries inline tests —
|
|
13
13
|
`tests={Input(...): Block() | Warn() | Allow()}` — run with `uvx capt-hook test`. This
|
|
14
14
|
skill turns **one durable correction** (the user's verbatim feedback plus the context it
|
|
15
15
|
fired in) into **one new hook file** `.claude/hooks/<slug>.py`. Full API:
|
|
@@ -111,7 +111,7 @@ hook.
|
|
|
111
111
|
### 5. Wire settings
|
|
112
112
|
|
|
113
113
|
Only after Step 4 is green, and only when the hook targets an event no existing
|
|
114
|
-
`.claude/settings.
|
|
114
|
+
`.claude/settings.json` entry dispatches:
|
|
115
115
|
|
|
116
116
|
```bash
|
|
117
117
|
uvx capt-hook register-hooks
|
{capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md
RENAMED
|
@@ -159,7 +159,7 @@ Glob caveat: patterns match the full relative path. `**/*.py` matches `src/main.
|
|
|
159
159
|
|---|---|
|
|
160
160
|
| `uvx capt-hook init` | Scaffold `.claude/hooks/example.py` + merge settings entries |
|
|
161
161
|
| `uvx capt-hook test [--json]` | Run all inline tests; exit 1 on failure; `--json` = one record per test |
|
|
162
|
-
| `uvx capt-hook register-hooks [--hooks-dir D] [--dry-run] [--from SRC]` | Merge captain-hook's hooks into `.claude/settings.
|
|
162
|
+
| `uvx capt-hook register-hooks [--hooks-dir D] [--dry-run] [--from SRC]` | Merge captain-hook's hooks into `.claude/settings.json` and write it (`--dry-run` prints without writing) |
|
|
163
163
|
| `uvx capt-hook run <Event> [--async]` | Dispatch one event (Claude Code calls this, not you) |
|
|
164
164
|
| `uvx capt-hook logs [--session S] [--tail N]` | View a recent capt-hook session log |
|
|
165
165
|
|
{capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md
RENAMED
|
@@ -44,7 +44,7 @@ broken hook.
|
|
|
44
44
|
|
|
45
45
|
Wire only commands proven to run: execute the exact settings command by hand first, and
|
|
46
46
|
prefer `uvx capt-hook register-hooks` (which writes known-good entries) over editing
|
|
47
|
-
`.claude/settings.
|
|
47
|
+
`.claude/settings.json` manually.
|
|
48
48
|
|
|
49
49
|
## 4. `uvx capt-hook test` green BEFORE wiring — always
|
|
50
50
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: bootstrapping-hooks
|
|
3
|
-
description: Surveys a repository and sets up captain-hook (capt-hook) guardrails for Claude Code — blocking gates, advisory nudges, command blocks, and test-integrity checks mined from the repo's own docs, CI workflows, lint configs, and git history. Scaffolds the framework and enables the session reviewer up front (Step 1), then proposes categorized candidates for user approval before writing anything, then writes .claude/hooks/*.py with inline tests, verifies with capt-hook test, and wires .claude/settings.
|
|
3
|
+
description: Surveys a repository and sets up captain-hook (capt-hook) guardrails for Claude Code — blocking gates, advisory nudges, command blocks, and test-integrity checks mined from the repo's own docs, CI workflows, lint configs, and git history. Scaffolds the framework and enables the session reviewer up front (Step 1), then proposes categorized candidates for user approval before writing anything, then writes .claude/hooks/*.py with inline tests, verifies with capt-hook test, and wires .claude/settings.json. Use when the user asks to "set up captain hook", "set up capt-hook", "set up hooks", "bootstrap capt-hook", "add guardrails", "enforce our conventions with hooks", "protect this repo", or "make Claude follow CONTRIBUTING.md".
|
|
4
4
|
argument-hint: "[repo path] (defaults to current project)"
|
|
5
5
|
allowed-tools: Read, Grep, Glob, AskUserQuestion, Write, Edit, Bash(uvx capt-hook:*, capt-hook:*, git log:*, git diff:*, ls:*, find:*)
|
|
6
6
|
---
|
|
@@ -9,7 +9,7 @@ allowed-tools: Read, Grep, Glob, AskUserQuestion, Write, Edit, Bash(uvx capt-hoo
|
|
|
9
9
|
|
|
10
10
|
capt-hook is a declarative hook framework for Claude Code. Hooks are Python files in
|
|
11
11
|
`.claude/hooks/`, dispatched by `uvx capt-hook run <Event>` entries in
|
|
12
|
-
`.claude/settings.
|
|
12
|
+
`.claude/settings.json`. Each hook carries inline tests —
|
|
13
13
|
`tests={Input(...): Block() | Warn() | Allow()}` — run with `uvx capt-hook test`. Hooks are
|
|
14
14
|
always Python regardless of the target repo's language: conditions like `Command` and
|
|
15
15
|
`FilePath` are language-agnostic; only AST `lint` rules are Python-specific. The full
|
|
@@ -51,11 +51,11 @@ grep -lq 'capt-hook' .claude/settings.json 2>/dev/null && echo COMMITTED || echo
|
|
|
51
51
|
|
|
52
52
|
Then scaffold up front, so the framework and the session reviewer are live before you propose
|
|
53
53
|
anything. Run `uvx capt-hook init` in every repo. It scaffolds `.claude/hooks/`,
|
|
54
|
-
wires `.claude/settings.
|
|
54
|
+
wires `.claude/settings.json`, installs the skills, and **enables the session reviewer**
|
|
55
55
|
(watching this repo; it mines ended sessions and opens hook PRs — `uvx capt-hook review disable`
|
|
56
|
-
to stop).
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
to stop). When `.claude/settings.local.json` already runs `uvx capt-hook run …` for some events
|
|
57
|
+
(a per-machine setup), `init` defers those events to the local file instead of duplicating them
|
|
58
|
+
into the committed settings. It prints `deferred to settings.local.json: …` and never double-fires.
|
|
59
59
|
|
|
60
60
|
Read `.claude/settings.local.json` and `.claude/settings.json`. If capt-hook hooks already exist,
|
|
61
61
|
switch to **additive mode**: never overwrite existing hook files; new categories go in new files,
|
|
@@ -154,7 +154,7 @@ after scaffolding). Run:
|
|
|
154
154
|
uvx capt-hook register-hooks
|
|
155
155
|
```
|
|
156
156
|
|
|
157
|
-
`register-hooks` writes `.claude/settings.
|
|
157
|
+
`register-hooks` writes `.claude/settings.json` directly, merging non-destructively: it
|
|
158
158
|
preserves every non-captain-hook entry, refreshes captain-hook's own, and drops entries for
|
|
159
159
|
events you no longer subscribe to. Add `--dry-run` to print the merged JSON without writing.
|
|
160
160
|
|
{capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/skills/scanning-sessions/references/review-cli.md
RENAMED
|
@@ -25,7 +25,7 @@ defaults to the process cwd. This is what spawned you; do not recurse into it.
|
|
|
25
25
|
### `review enable` / `review disable`
|
|
26
26
|
|
|
27
27
|
`enable` marks the current repo watched and wires the SessionEnd hook into
|
|
28
|
-
`.claude/settings.
|
|
28
|
+
`.claude/settings.json` (idempotent). `disable` stops watching; candidates stay
|
|
29
29
|
recorded but never become eligible.
|
|
30
30
|
|
|
31
31
|
### `review scan [--transcript <file>]... [--dir <dir>]...`
|
|
@@ -166,7 +166,7 @@ and every `Input` runs through the *whole* styleguide. A failing test usually me
|
|
|
166
166
|
input trips a sibling rule — shrink it to a single construct that trips exactly one rule.
|
|
167
167
|
|
|
168
168
|
If `style_llm.py` added hooks on new events (e.g. a `Stop`-targeted `llm_nudge`), run
|
|
169
|
-
`uvx capt-hook register-hooks` (it merges non-destructively into `.claude/settings.
|
|
169
|
+
`uvx capt-hook register-hooks` (it merges non-destructively into `.claude/settings.json`
|
|
170
170
|
and writes it).
|
|
171
171
|
|
|
172
172
|
### 8. Enforcement report
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "capt-hook"
|
|
3
|
-
version = "3.
|
|
3
|
+
version = "3.8.0"
|
|
4
4
|
description = "Declarative hook framework for Claude Code"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "PolyForm-Noncommercial-1.0.0"
|
|
@@ -20,7 +20,7 @@ classifiers = [
|
|
|
20
20
|
]
|
|
21
21
|
requires-python = ">=3.13"
|
|
22
22
|
dependencies = [
|
|
23
|
-
"cc-transcript>=
|
|
23
|
+
"cc-transcript>=4,<5",
|
|
24
24
|
"pydantic>=2.0",
|
|
25
25
|
"pydantic-settings>=2.0",
|
|
26
26
|
"tree-sitter>=0.24",
|
|
@@ -34,7 +34,7 @@ dependencies = [
|
|
|
34
34
|
"lazy-object-proxy>=1.12.0",
|
|
35
35
|
"filelock>=3",
|
|
36
36
|
"loguru>=0.7.3",
|
|
37
|
-
"spawnllm>=0.
|
|
37
|
+
"spawnllm>=0.2.0",
|
|
38
38
|
]
|
|
39
39
|
|
|
40
40
|
[project.optional-dependencies]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md
RENAMED
|
File without changes
|
|
File without changes
|
{capt_hook-3.6.0 → capt_hook-3.8.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|