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/app.py ADDED
@@ -0,0 +1,278 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from collections.abc import Callable, Sequence
5
+ from dataclasses import dataclass, field
6
+ from fnmatch import fnmatch
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, get_args
9
+
10
+ from pydantic_settings import BaseSettings
11
+
12
+ from captain_hook.conditions import matches_conditions
13
+ from captain_hook.types import (
14
+ CustomCondition,
15
+ Event,
16
+ HookSpec,
17
+ InlineTests,
18
+ RegisteredHook,
19
+ TCondition,
20
+ )
21
+
22
+ if TYPE_CHECKING:
23
+ from captain_hook.classifiers import MessageClassifier
24
+ from captain_hook.events import BaseHookEvent
25
+ from captain_hook.types import HookResult
26
+
27
+ HookHandler = Callable[["BaseHookEvent"], "HookResult | None"]
28
+
29
+ VALID_CONDITION_TYPES = tuple(
30
+ t for t in get_args(TCondition) if t is not CustomCondition
31
+ )
32
+ VALID_CONDITION_NAMES = ", ".join(t.__name__ for t in VALID_CONDITION_TYPES) + ", or a CustomCondition"
33
+
34
+
35
+ def validate_conditions(conditions: Sequence[TCondition], label: str) -> None:
36
+ for c in conditions:
37
+ if not isinstance(c, (*VALID_CONDITION_TYPES, CustomCondition)):
38
+ raise TypeError(
39
+ f"Invalid condition in {label}: {c!r} (type {type(c).__name__}). "
40
+ f"Expected one of: {VALID_CONDITION_NAMES}."
41
+ )
42
+
43
+
44
+ def validate_handler_signature(fn: HookHandler) -> None:
45
+ sig = inspect.signature(fn)
46
+ params = [
47
+ p for p in sig.parameters.values()
48
+ if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
49
+ ]
50
+ if len(params) != 1:
51
+ raise TypeError(
52
+ f"Handler {fn.__name__} has wrong signature: expected (evt) -> HookResult | None, "
53
+ f"got {sig}. Hook handlers must accept exactly one positional parameter (the event)."
54
+ )
55
+ required_kw = [
56
+ p for p in sig.parameters.values()
57
+ if p.kind == inspect.Parameter.KEYWORD_ONLY and p.default is inspect.Parameter.empty
58
+ ]
59
+ if required_kw:
60
+ names = ", ".join(p.name for p in required_kw)
61
+ raise TypeError(
62
+ f"Handler {fn.__name__} has required keyword-only parameter(s): {names}. "
63
+ f"Hook handlers are called as handler(evt) — keyword-only parameters must have defaults."
64
+ )
65
+
66
+
67
+ @dataclass
68
+ class State:
69
+ hooks: list[RegisteredHook] = field(default_factory=list)
70
+ gitignore_patterns: list[str] = field(default_factory=list)
71
+ settings: BaseSettings | None = None
72
+ classifier: MessageClassifier | None = None
73
+ counter: int = field(default=0, repr=False)
74
+
75
+
76
+ _state = State()
77
+
78
+
79
+ def reset() -> None:
80
+ _state.hooks.clear()
81
+ _state.gitignore_patterns.clear()
82
+ _state.counter = 0
83
+ _state.settings = None
84
+ _state.classifier = None
85
+
86
+
87
+ def load_gitignore(root: Path) -> None:
88
+ _state.gitignore_patterns.clear()
89
+ if not (gitignore := root / ".gitignore").exists():
90
+ return
91
+ _state.gitignore_patterns.extend(
92
+ line.rstrip("/")
93
+ for raw in gitignore.read_text().splitlines()
94
+ if (line := raw.strip()) and not line.startswith("#")
95
+ )
96
+
97
+
98
+ def is_gitignored(path_str: str) -> bool:
99
+ if not _state.gitignore_patterns:
100
+ return False
101
+ p = Path(path_str)
102
+ return any(
103
+ fnmatch(p.name, pat) or any(fnmatch(part, pat) for part in p.parts) for pat in _state.gitignore_patterns
104
+ )
105
+
106
+
107
+ def hook(
108
+ events: Event,
109
+ *,
110
+ only_if: Sequence[TCondition] = (),
111
+ skip_if: Sequence[TCondition] = (),
112
+
113
+ message: str | None = None,
114
+ block: bool = False,
115
+ respect_gitignore: bool = True,
116
+ max_fires: int | None = None,
117
+ tests: InlineTests | None = None,
118
+ async_: bool = False,
119
+ skip_planning_agents: bool = True,
120
+ ) -> None:
121
+ if message is None:
122
+ raise TypeError(
123
+ "hook() requires message= for declarative hooks. "
124
+ "Provide message='...' or use @on() for handler-based hooks."
125
+ )
126
+ validate_conditions(only_if, "only_if")
127
+ validate_conditions(skip_if, "skip_if")
128
+ _state.counter += 1
129
+ _state.hooks.append(
130
+ RegisteredHook(
131
+ spec=HookSpec(
132
+ events=events,
133
+ only_if=tuple(only_if),
134
+ skip_if=tuple(skip_if),
135
+ message=message,
136
+ block=block,
137
+ respect_gitignore=respect_gitignore,
138
+ max_fires=max_fires,
139
+ tests=tests,
140
+ async_=async_,
141
+ skip_planning_agents=skip_planning_agents,
142
+ ),
143
+ name=f"declarative_{_state.counter}",
144
+ )
145
+ )
146
+
147
+
148
+ def on(
149
+ events: Event,
150
+ *,
151
+ only_if: Sequence[TCondition] = (),
152
+ skip_if: Sequence[TCondition] = (),
153
+ respect_gitignore: bool = True,
154
+ max_fires: int | None = None,
155
+ tests: InlineTests | None = None,
156
+ async_: bool = False,
157
+ skip_planning_agents: bool = True,
158
+ ) -> Callable[[HookHandler], HookHandler]:
159
+ validate_conditions(only_if, "only_if")
160
+ validate_conditions(skip_if, "skip_if")
161
+ spec = HookSpec(
162
+ events=events,
163
+ only_if=tuple(only_if),
164
+ skip_if=tuple(skip_if),
165
+ respect_gitignore=respect_gitignore,
166
+ max_fires=max_fires,
167
+ tests=tests,
168
+ async_=async_,
169
+ skip_planning_agents=skip_planning_agents,
170
+ )
171
+
172
+ def decorator(fn: HookHandler) -> HookHandler:
173
+ validate_handler_signature(fn)
174
+ _state.hooks.append(
175
+ RegisteredHook(
176
+ spec=spec,
177
+ handler=fn,
178
+ name=fn.__name__,
179
+ source_file=fn.__code__.co_filename,
180
+ )
181
+ )
182
+ return fn
183
+
184
+ return decorator
185
+
186
+
187
+ def register(
188
+ events: Event,
189
+ *,
190
+ only_if: Sequence[TCondition] = (),
191
+ skip_if: Sequence[TCondition] = (),
192
+ message: str | None = None,
193
+ block: bool = False,
194
+ respect_gitignore: bool = True,
195
+ max_fires: int | None = None,
196
+ tests: InlineTests | None = None,
197
+ async_: bool = False,
198
+ skip_planning_agents: bool = True,
199
+ ) -> Callable[[HookHandler], HookHandler] | None:
200
+ validate_conditions(only_if, "only_if")
201
+ validate_conditions(skip_if, "skip_if")
202
+
203
+ if message is not None:
204
+ hook(
205
+ events,
206
+ only_if=only_if,
207
+ skip_if=skip_if,
208
+ message=message,
209
+ block=block,
210
+ respect_gitignore=respect_gitignore,
211
+ max_fires=max_fires,
212
+ tests=tests,
213
+ async_=async_,
214
+ skip_planning_agents=skip_planning_agents,
215
+ )
216
+ return None
217
+
218
+ if block:
219
+ raise TypeError(
220
+ "hook() called with block=True but no message= provided. "
221
+ "Declarative hooks require message= to specify the block reason. "
222
+ "Either provide message='...' or use @on() for handler-based hooks."
223
+ )
224
+
225
+ spec = HookSpec(
226
+ events=events,
227
+ only_if=tuple(only_if),
228
+ skip_if=tuple(skip_if),
229
+ block=block,
230
+ respect_gitignore=respect_gitignore,
231
+ max_fires=max_fires,
232
+ tests=tests,
233
+ async_=async_,
234
+ skip_planning_agents=skip_planning_agents,
235
+ )
236
+
237
+ def decorator(fn: HookHandler) -> HookHandler:
238
+ validate_handler_signature(fn)
239
+ _state.hooks.append(
240
+ RegisteredHook(
241
+ spec=spec,
242
+ handler=fn,
243
+ name=fn.__name__,
244
+ source_file=fn.__code__.co_filename,
245
+ )
246
+ )
247
+ return fn
248
+
249
+ return decorator
250
+
251
+
252
+ def is_planning_agent_skip(spec: HookSpec, evt: BaseHookEvent) -> bool:
253
+ from captain_hook.settings import DEFAULT_PLANNING_AGENTS
254
+
255
+ if not spec.skip_planning_agents:
256
+ return False
257
+ if evt.event not in (Event.SubagentStop | Event.SubagentStart):
258
+ return False
259
+ names = getattr(_state.settings, "planning_agents", DEFAULT_PLANNING_AGENTS)
260
+ return bool(evt.agent_type and evt.agent_type in names)
261
+
262
+
263
+ def get_matching_hooks(evt: BaseHookEvent) -> list[RegisteredHook]:
264
+ return [
265
+ h
266
+ for h in _state.hooks
267
+ if evt.event in h.spec.events
268
+ and not is_planning_agent_skip(h.spec, evt)
269
+ and matches_conditions(h.spec, evt)
270
+ and (
271
+ not h.spec.respect_gitignore
272
+ or not _state.gitignore_patterns
273
+ or not evt.file
274
+ or not is_gitignored(str(evt.file))
275
+ )
276
+ ]
277
+
278
+
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
5
+
6
+ if TYPE_CHECKING:
7
+ from captain_hook.transcript.models import TranscriptMessage
8
+
9
+
10
+ @runtime_checkable
11
+ class MessageClassifier(Protocol):
12
+ """Callable that returns True for messages classified as real user messages."""
13
+
14
+ def __call__(self, msg: TranscriptMessage) -> bool: ...
15
+
16
+ CLASSIFIER_MODULES = ("droid", "conductor", "native")
17
+
18
+
19
+ def detect(
20
+ cwd: str | None = None,
21
+ transcript_path: str | None = None,
22
+ messages: list[TranscriptMessage] | None = None,
23
+ ) -> MessageClassifier:
24
+ """Auto-detect the environment and return the appropriate message classifier."""
25
+ return next(
26
+ mod.classifier
27
+ for name in CLASSIFIER_MODULES
28
+ if (mod := importlib.import_module(f".{name}", __package__))
29
+ and mod.detect(cwd=cwd, transcript_path=transcript_path, messages=messages)
30
+ )
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from captain_hook.transcript.models import TranscriptMessage
7
+
8
+ SYNTHETIC_PREFIXES = (
9
+ "<system_instruction>",
10
+ "<task-notification>",
11
+ "<local-command-caveat>",
12
+ "<command-name>",
13
+ )
14
+
15
+
16
+ def classifier(msg: TranscriptMessage) -> bool:
17
+ if msg.type != "user":
18
+ return False
19
+ text = msg.text.strip()
20
+ return bool(text) and not text.startswith(SYNTHETIC_PREFIXES)
21
+
22
+
23
+ def detect(
24
+ *,
25
+ cwd: str | None = None,
26
+ transcript_path: str | None = None,
27
+ messages: list[TranscriptMessage] | None = None,
28
+ ) -> bool:
29
+ if cwd and "conductor/workspaces" in cwd:
30
+ return True
31
+ if transcript_path and "conductor-workspaces" in transcript_path:
32
+ return True
33
+ if messages:
34
+ return any(m.type == "user" and m.text.strip().startswith("<system_instruction>") for m in messages[:50])
35
+ return False
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from captain_hook.transcript.models import TranscriptMessage
8
+
9
+
10
+ def classifier(msg: TranscriptMessage) -> bool:
11
+ return msg.type == "user" and bool(msg.text.strip())
12
+
13
+
14
+ def detect(
15
+ *,
16
+ cwd: str | None = None,
17
+ transcript_path: str | None = None,
18
+ messages: list[TranscriptMessage] | None = None,
19
+ ) -> bool:
20
+ return "FACTORY_PROJECT_DIR" in os.environ
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from captain_hook.transcript.models import TranscriptMessage
7
+
8
+
9
+ def classifier(msg: TranscriptMessage) -> bool:
10
+ return msg.type == "user" and bool(msg.text.strip())
11
+
12
+
13
+ def detect(
14
+ *,
15
+ cwd: str | None = None,
16
+ transcript_path: str | None = None,
17
+ messages: list[TranscriptMessage] | None = None,
18
+ ) -> bool:
19
+ return True