capt-hook 0.2.0__py3-none-any.whl

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 (57) hide show
  1. capt_hook-0.2.0.dist-info/METADATA +113 -0
  2. capt_hook-0.2.0.dist-info/RECORD +57 -0
  3. capt_hook-0.2.0.dist-info/WHEEL +4 -0
  4. capt_hook-0.2.0.dist-info/entry_points.txt +3 -0
  5. capt_hook-0.2.0.dist-info/licenses/LICENSE +73 -0
  6. captain_hook/__init__.py +246 -0
  7. captain_hook/__main__.py +6 -0
  8. captain_hook/app.py +278 -0
  9. captain_hook/classifiers/__init__.py +30 -0
  10. captain_hook/classifiers/conductor.py +35 -0
  11. captain_hook/classifiers/droid.py +20 -0
  12. captain_hook/classifiers/native.py +19 -0
  13. captain_hook/cli.py +341 -0
  14. captain_hook/command.py +356 -0
  15. captain_hook/conditions.py +136 -0
  16. captain_hook/context.py +161 -0
  17. captain_hook/dispatch.py +107 -0
  18. captain_hook/events.py +318 -0
  19. captain_hook/file.py +120 -0
  20. captain_hook/llm/__init__.py +9 -0
  21. captain_hook/llm/backends.py +152 -0
  22. captain_hook/loader.py +62 -0
  23. captain_hook/log.py +60 -0
  24. captain_hook/primitives/__init__.py +51 -0
  25. captain_hook/primitives/audit.py +71 -0
  26. captain_hook/primitives/commands.py +61 -0
  27. captain_hook/primitives/lint.py +216 -0
  28. captain_hook/primitives/llm.py +376 -0
  29. captain_hook/primitives/nudge.py +95 -0
  30. captain_hook/prompt.py +103 -0
  31. captain_hook/py.typed +1 -0
  32. captain_hook/session.py +158 -0
  33. captain_hook/settings.py +120 -0
  34. captain_hook/signals/__init__.py +86 -0
  35. captain_hook/signals/nlp.py +105 -0
  36. captain_hook/state.py +221 -0
  37. captain_hook/styleguide/__init__.py +183 -0
  38. captain_hook/styleguide/query.py +238 -0
  39. captain_hook/styleguide/scope.py +46 -0
  40. captain_hook/styleguide/types.py +70 -0
  41. captain_hook/tasks.py +112 -0
  42. captain_hook/templates/example_hook.py.tmpl +85 -0
  43. captain_hook/testing/__init__.py +10 -0
  44. captain_hook/testing/helpers.py +392 -0
  45. captain_hook/testing/session_cache.py +50 -0
  46. captain_hook/testing/types.py +88 -0
  47. captain_hook/tests/__init__.py +27 -0
  48. captain_hook/tests/helpers.py +361 -0
  49. captain_hook/tools.py +59 -0
  50. captain_hook/transcript/__init__.py +572 -0
  51. captain_hook/transcript/inputs.py +226 -0
  52. captain_hook/transcript/models.py +186 -0
  53. captain_hook/types.py +381 -0
  54. captain_hook/util/__init__.py +0 -0
  55. captain_hook/util/model_cache.py +87 -0
  56. captain_hook/utils.py +27 -0
  57. captain_hook/workflow.py +119 -0
captain_hook/log.py ADDED
@@ -0,0 +1,60 @@
1
+ """Captain-hook logging: one per-session loguru file sink plus stderr warnings.
2
+
3
+ Modules log through loguru directly (``from loguru import logger``), attaching
4
+ structured context with ``logger.bind(...)``. ``setup_logging()`` installs the
5
+ sinks and a patcher that truncates long bound values so call sites never have to
6
+ slice them by hand.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ from typing import TYPE_CHECKING
13
+
14
+ from loguru import logger
15
+
16
+ from captain_hook.session import session_hash
17
+ from captain_hook.settings import resolve_log_dir
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Callable
21
+
22
+ from loguru import Record
23
+
24
+ MAX_BOUND_VALUE = 200
25
+
26
+
27
+ def truncate_bound_values(record: Record) -> None:
28
+ """Patcher: cap long string values bound via ``logger.bind(...)``."""
29
+ for key, value in record["extra"].items():
30
+ if isinstance(value, str) and len(value) > MAX_BOUND_VALUE:
31
+ record["extra"][key] = value[:MAX_BOUND_VALUE] + "…"
32
+
33
+
34
+ def make_format(template: str) -> Callable[[Record], str]:
35
+ """Build a loguru format function that appends bound context and the traceback."""
36
+
37
+ def formatter(record: Record) -> str:
38
+ return (template + " | {extra}" if record["extra"] else template) + "\n{exception}"
39
+
40
+ return formatter
41
+
42
+
43
+ def setup_logging(transcript_path: str | None) -> None:
44
+ """Route captain-hook logs to a per-session loguru file sink plus stderr warnings."""
45
+ session_id = session_hash(transcript_path) if transcript_path else "unknown"
46
+ log_dir = resolve_log_dir()
47
+ log_dir.mkdir(parents=True, exist_ok=True)
48
+
49
+ logger.configure(
50
+ patcher=truncate_bound_values,
51
+ handlers=[
52
+ {
53
+ "sink": str(log_dir / f"{session_id}.log"),
54
+ "level": "DEBUG",
55
+ "format": make_format("{time:YYYY-MM-DD HH:mm:ss,SSS} {level} {name}: {message}"),
56
+ "encoding": "utf-8",
57
+ },
58
+ {"sink": sys.stderr, "level": "WARNING", "format": make_format("{level}: {message}")},
59
+ ],
60
+ )
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ from captain_hook.primitives.audit import audit as audit
4
+ from captain_hook.primitives.audit import session_id_for as session_id_for
5
+ from captain_hook.primitives.commands import block_command as block_command
6
+ from captain_hook.primitives.commands import warn_command as warn_command
7
+ from captain_hook.primitives.lint import diff_lint as diff_lint
8
+ from captain_hook.primitives.lint import lint as lint
9
+ from captain_hook.primitives.llm import (
10
+ GateVerdict as GateVerdict,
11
+ )
12
+ from captain_hook.primitives.llm import (
13
+ NudgeVerdict as NudgeVerdict,
14
+ )
15
+ from captain_hook.primitives.llm import (
16
+ PromptCheckVerdict as PromptCheckVerdict,
17
+ )
18
+ from captain_hook.primitives.llm import (
19
+ llm_evaluate as llm_evaluate,
20
+ )
21
+ from captain_hook.primitives.llm import (
22
+ llm_gate as llm_gate,
23
+ )
24
+ from captain_hook.primitives.llm import (
25
+ llm_nudge as llm_nudge,
26
+ )
27
+ from captain_hook.primitives.llm import (
28
+ prompt_check as prompt_check,
29
+ )
30
+ from captain_hook.primitives.nudge import gate as gate
31
+ from captain_hook.primitives.nudge import nudge as nudge
32
+ from captain_hook.styleguide import styleguide as styleguide
33
+
34
+ __all__ = [
35
+ "GateVerdict",
36
+ "NudgeVerdict",
37
+ "PromptCheckVerdict",
38
+ "audit",
39
+ "block_command",
40
+ "diff_lint",
41
+ "gate",
42
+ "lint",
43
+ "llm_evaluate",
44
+ "llm_gate",
45
+ "llm_nudge",
46
+ "nudge",
47
+ "prompt_check",
48
+ "session_id_for",
49
+ "styleguide",
50
+ "warn_command",
51
+ ]
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from collections.abc import Callable, Sequence
6
+ from datetime import UTC, datetime
7
+ from hashlib import sha256
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from captain_hook.app import on
12
+ from captain_hook.types import Event, HookResult, TCondition
13
+
14
+ if TYPE_CHECKING:
15
+ from captain_hook.events import BaseHookEvent
16
+
17
+
18
+ def session_id_for(evt: BaseHookEvent) -> str | None:
19
+ """Return a 12-char sha256 prefix of the transcript path, or ``None`` if unavailable."""
20
+ return sha256(str(p).encode()).hexdigest()[:12] if (p := evt.ctx.t.path) else None
21
+
22
+
23
+ def default_log_dir() -> Path:
24
+ return Path(os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())) / ".context" / "hook-logs"
25
+
26
+
27
+ def default_fields(evt: BaseHookEvent) -> dict[str, Any]:
28
+ return {
29
+ "ts": datetime.now(UTC).isoformat(),
30
+ "event": evt.event_name.name,
31
+ "tool": evt.tool_name,
32
+ "file": str(evt.file.path) if evt.file else None,
33
+ "session_id": session_id_for(evt),
34
+ }
35
+
36
+
37
+ def audit(
38
+ events: Event = Event.PreToolUse | Event.PostToolUse | Event.Stop,
39
+ *,
40
+ log_dir: Path | str | None = None,
41
+ filename: Callable[[datetime], str] = lambda d: f"{d:%Y-%m-%d}.jsonl",
42
+ fields: Callable[[BaseHookEvent], dict[str, Any]] = default_fields,
43
+ only_if: Sequence[TCondition] = (),
44
+ skip_if: Sequence[TCondition] = (),
45
+ ) -> None:
46
+ """Register a hook that appends one JSONL record per matching event.
47
+
48
+ Each matching event writes a single line to ``<log_dir>/<filename(now)>``.
49
+ Default fields are ``ts``, ``event``, ``tool``, ``file``, and ``session_id``
50
+ (a 12-char sha256 prefix of the transcript path).
51
+
52
+ Example:
53
+ >>> from captain_hook import audit, Event
54
+ >>> audit(Event.PreToolUse | Event.PostToolUse | Event.Stop)
55
+
56
+ Args:
57
+ events: Event mask to audit. Defaults to PreToolUse | PostToolUse | Stop.
58
+ log_dir: Output directory. Defaults to ``$CLAUDE_PROJECT_DIR/.context/hook-logs``.
59
+ filename: ``(datetime) -> str`` mapping a timestamp to a filename.
60
+ fields: ``(evt) -> dict`` for the per-record payload.
61
+ only_if: Conditions that must match for the event to be recorded.
62
+ skip_if: Conditions that, if matched, suppress recording.
63
+ """
64
+ resolved_dir = Path(log_dir) if log_dir else default_log_dir()
65
+
66
+ @on(events, only_if=only_if, skip_if=skip_if)
67
+ def audit_event(evt: BaseHookEvent) -> HookResult | None:
68
+ resolved_dir.mkdir(parents=True, exist_ok=True)
69
+ with (resolved_dir / filename(datetime.now(UTC))).open("a") as f:
70
+ f.write(json.dumps(fields(evt)) + "\n")
71
+ return None
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ from captain_hook.app import hook as register_hook
6
+ from captain_hook.types import Command, Event, InlineTests, Tool
7
+
8
+
9
+ def block_command_pattern(tokens: list[str]) -> str:
10
+ """Convert a token list into a whitespace-flexible regex pattern.
11
+
12
+ ``"*"`` becomes ``\\S+`` (any non-whitespace word), ``"a|b"`` becomes an
13
+ alternation group, and all other tokens are escaped.
14
+
15
+ Example:
16
+ >>> block_command_pattern(["git", "stash", "*"])
17
+ 'git\\\\s+stash\\\\s+\\\\S+'
18
+ """
19
+ def convert(token: str) -> str:
20
+ match token:
21
+ case "*":
22
+ return r"\S+"
23
+ case t if "|" in t:
24
+ return f"(?:{'|'.join(re.escape(a) for a in t.split('|'))})"
25
+ case t:
26
+ return re.escape(t)
27
+
28
+ return r"\s+".join(convert(t) for t in tokens)
29
+
30
+
31
+ def block_command(
32
+ pattern: str | list[str],
33
+ *,
34
+ reason: str,
35
+ hint: str | None = None,
36
+ tests: InlineTests | None = None,
37
+ ) -> None:
38
+ """Register a declarative hook that blocks a Bash command matching a pattern.
39
+
40
+ Example:
41
+ >>> block_command(["git", "stash"], reason="git stash is not allowed", hint="Use jj")
42
+ """
43
+ msg = f"BLOCKED: {reason}.{f' {hint}.' if hint else ''}"
44
+ cmd = Command(block_command_pattern(pattern) if isinstance(pattern, list) else pattern)
45
+ register_hook(Event.PreToolUse, only_if=[Tool("Bash"), cmd], message=msg, block=True, tests=tests)
46
+
47
+
48
+ def warn_command(
49
+ pattern: str | list[str],
50
+ *,
51
+ message: str,
52
+ tests: InlineTests | None = None,
53
+ events: Event = Event.PostToolUse,
54
+ ) -> None:
55
+ """Register a declarative hook that warns on a Bash command matching a pattern.
56
+
57
+ Example:
58
+ >>> warn_command(["python", "-c", "*"], message="Prefer uv run mtest")
59
+ """
60
+ cmd = Command(block_command_pattern(pattern) if isinstance(pattern, list) else pattern)
61
+ register_hook(events, only_if=[Tool("Bash"), cmd], message=message, tests=tests)
@@ -0,0 +1,216 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import textwrap
5
+ from collections.abc import Callable, Iterator, Sequence
6
+ from typing import TYPE_CHECKING, get_type_hints, overload
7
+
8
+ from loguru import logger
9
+
10
+ from captain_hook.app import on
11
+ from captain_hook.state import hook_name
12
+ from captain_hook.types import (
13
+ Action,
14
+ Event,
15
+ FilePath,
16
+ HookResult,
17
+ InlineTests,
18
+ TCondition,
19
+ TestFile,
20
+ Tool,
21
+ )
22
+
23
+ if TYPE_CHECKING:
24
+ from captain_hook.events import BaseHookEvent
25
+
26
+ StringCheck = Callable[[str], list[str]]
27
+ AstCheck = Callable[[ast.AST], Iterator[str]]
28
+ DiffCheck = Callable[[ast.AST, ast.AST], list[str]]
29
+
30
+ DEFAULT_ONLY_IF: tuple[TCondition, ...] = (Tool("Edit|Write"), FilePath("*.py", project_only=False))
31
+ DEFAULT_SKIP_IF: tuple[TCondition, ...] = (TestFile(),)
32
+ DIFF_DEFAULT_ONLY_IF: tuple[TCondition, ...] = (Tool("Edit"), FilePath("*.py", project_only=False))
33
+
34
+
35
+ def detect_ast_mode(check: StringCheck | AstCheck) -> bool:
36
+ hints = get_type_hints(check)
37
+ first_param = next(iter(hints.values()), None)
38
+ return first_param is ast.AST or (isinstance(first_param, type) and issubclass(first_param, ast.AST))
39
+
40
+
41
+ def read_source(evt: BaseHookEvent) -> str | None:
42
+ if evt.file and evt.file.path.exists():
43
+ try:
44
+ return evt.file.read_text()
45
+ except (OSError, UnicodeDecodeError):
46
+ pass
47
+ return evt.content
48
+
49
+
50
+ @overload
51
+ def lint(
52
+ check: Callable[[str], list[str]],
53
+ *,
54
+ message: str,
55
+ trigger: str | None = ...,
56
+ sep: str = ...,
57
+ block: bool = ...,
58
+ events: Event | None = ...,
59
+ tests: InlineTests | None = ...,
60
+ max_shown: int = ...,
61
+ ) -> None: ...
62
+
63
+
64
+ @overload
65
+ def lint(
66
+ check: Callable[[ast.AST], Iterator[str]],
67
+ *,
68
+ message: str,
69
+ trigger: str | None = ...,
70
+ sep: str = ...,
71
+ block: bool = ...,
72
+ events: Event | None = ...,
73
+ tests: InlineTests | None = ...,
74
+ max_shown: int = ...,
75
+ ) -> None: ...
76
+
77
+
78
+ def lint(
79
+ check: Callable[[str], list[str]] | Callable[[ast.AST], Iterator[str]],
80
+ *,
81
+ message: str,
82
+ trigger: str | None = None,
83
+ sep: str = ", ",
84
+ block: bool = False,
85
+ events: Event | None = None,
86
+ tests: InlineTests | None = None,
87
+ max_shown: int = 5,
88
+ ) -> None:
89
+ """Register a lint check that runs on Python file edits/writes.
90
+
91
+ Supports two modes based on the ``check`` function's type hint:
92
+ - **String mode**: receives the file content as ``str``, returns violation strings.
93
+ - **AST mode**: receives each ``ast.AST`` node, yields violation strings.
94
+
95
+ Example:
96
+ >>> def find_prints(content: str) -> list[str]:
97
+ ... return [line for line in content.splitlines() if "print(" in line]
98
+ >>> lint(find_prints, message="Remove print statements: {violations}")
99
+ """
100
+ is_ast = detect_ast_mode(check)
101
+
102
+ def handler(evt: BaseHookEvent) -> HookResult | None:
103
+ try:
104
+ if is_ast:
105
+ return run_ast_check(check, evt, message, trigger, sep, block, max_shown) # type: ignore[arg-type]
106
+ return run_string_check(check, evt, message, sep, block, max_shown) # type: ignore[arg-type]
107
+ except Exception:
108
+ logger.bind(check=check.__name__).opt(exception=True).warning("lint check failed")
109
+ return None
110
+
111
+ handler.__name__ = handler.__qualname__ = hook_name("lint", None, message)
112
+
113
+ on(
114
+ events or Event.PostToolUse,
115
+ only_if=DEFAULT_ONLY_IF,
116
+ skip_if=DEFAULT_SKIP_IF,
117
+ tests=tests,
118
+ )(handler)
119
+
120
+
121
+ def diff_lint(
122
+ check: DiffCheck,
123
+ *,
124
+ message: str,
125
+ sep: str = ", ",
126
+ block: bool = False,
127
+ events: Event | None = None,
128
+ tests: InlineTests | None = None,
129
+ max_shown: int = 5,
130
+ only_if: Sequence[TCondition] = DIFF_DEFAULT_ONLY_IF,
131
+ skip_if: Sequence[TCondition] = DEFAULT_SKIP_IF,
132
+ ) -> None:
133
+ def handler(evt: BaseHookEvent) -> HookResult | None:
134
+ try:
135
+ return run_diff_check(check, evt, message, sep, block, max_shown)
136
+ except Exception:
137
+ logger.bind(check=check.__name__).opt(exception=True).warning("diff lint check failed")
138
+ return None
139
+
140
+ handler.__name__ = handler.__qualname__ = hook_name("diff_lint", None, message)
141
+
142
+ on(
143
+ events or Event.PostToolUse,
144
+ only_if=only_if,
145
+ skip_if=skip_if,
146
+ tests=tests,
147
+ )(handler)
148
+
149
+
150
+ def run_string_check(
151
+ check: StringCheck,
152
+ evt: BaseHookEvent,
153
+ message: str,
154
+ sep: str,
155
+ block: bool,
156
+ max_shown: int,
157
+ ) -> HookResult | None:
158
+ if (content := evt.content) is None:
159
+ return None
160
+ return format_result(check(content), message, sep, block, max_shown)
161
+
162
+
163
+ def run_diff_check(
164
+ check: DiffCheck,
165
+ evt: BaseHookEvent,
166
+ message: str,
167
+ sep: str,
168
+ block: bool,
169
+ max_shown: int,
170
+ ) -> HookResult | None:
171
+ if (old := evt.old) is None or (new := evt.content) is None:
172
+ return None
173
+ try:
174
+ old_tree = ast.parse(textwrap.dedent(old))
175
+ new_tree = ast.parse(textwrap.dedent(new))
176
+ except SyntaxError:
177
+ return None
178
+ return format_result(check(old_tree, new_tree), message, sep, block, max_shown)
179
+
180
+
181
+ def run_ast_check(
182
+ check: AstCheck,
183
+ evt: BaseHookEvent,
184
+ message: str,
185
+ trigger: str | None,
186
+ sep: str,
187
+ block: bool,
188
+ max_shown: int,
189
+ ) -> HookResult | None:
190
+ source = read_source(evt)
191
+ if source is None:
192
+ return None
193
+ if trigger and trigger not in source:
194
+ return None
195
+ try:
196
+ tree = ast.parse(source)
197
+ except SyntaxError:
198
+ return None
199
+ violations = [v for node in ast.walk(tree) for v in check(node)]
200
+ return format_result(violations, message, sep, block, max_shown)
201
+
202
+
203
+ def format_result(
204
+ violations: list[str],
205
+ message: str,
206
+ sep: str,
207
+ block: bool,
208
+ max_shown: int,
209
+ ) -> HookResult | None:
210
+ if not violations:
211
+ return None
212
+ formatted = sep.join(violations[:max_shown])
213
+ return HookResult(
214
+ action=Action.block if block else Action.warn,
215
+ message=message.format(violations=formatted),
216
+ )