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.
- capt_hook-0.2.0.dist-info/METADATA +113 -0
- capt_hook-0.2.0.dist-info/RECORD +57 -0
- capt_hook-0.2.0.dist-info/WHEEL +4 -0
- capt_hook-0.2.0.dist-info/entry_points.txt +3 -0
- capt_hook-0.2.0.dist-info/licenses/LICENSE +73 -0
- captain_hook/__init__.py +246 -0
- captain_hook/__main__.py +6 -0
- captain_hook/app.py +278 -0
- captain_hook/classifiers/__init__.py +30 -0
- captain_hook/classifiers/conductor.py +35 -0
- captain_hook/classifiers/droid.py +20 -0
- captain_hook/classifiers/native.py +19 -0
- captain_hook/cli.py +341 -0
- captain_hook/command.py +356 -0
- captain_hook/conditions.py +136 -0
- captain_hook/context.py +161 -0
- captain_hook/dispatch.py +107 -0
- captain_hook/events.py +318 -0
- captain_hook/file.py +120 -0
- captain_hook/llm/__init__.py +9 -0
- captain_hook/llm/backends.py +152 -0
- captain_hook/loader.py +62 -0
- captain_hook/log.py +60 -0
- captain_hook/primitives/__init__.py +51 -0
- captain_hook/primitives/audit.py +71 -0
- captain_hook/primitives/commands.py +61 -0
- captain_hook/primitives/lint.py +216 -0
- captain_hook/primitives/llm.py +376 -0
- captain_hook/primitives/nudge.py +95 -0
- captain_hook/prompt.py +103 -0
- captain_hook/py.typed +1 -0
- captain_hook/session.py +158 -0
- captain_hook/settings.py +120 -0
- captain_hook/signals/__init__.py +86 -0
- captain_hook/signals/nlp.py +105 -0
- captain_hook/state.py +221 -0
- captain_hook/styleguide/__init__.py +183 -0
- captain_hook/styleguide/query.py +238 -0
- captain_hook/styleguide/scope.py +46 -0
- captain_hook/styleguide/types.py +70 -0
- captain_hook/tasks.py +112 -0
- captain_hook/templates/example_hook.py.tmpl +85 -0
- captain_hook/testing/__init__.py +10 -0
- captain_hook/testing/helpers.py +392 -0
- captain_hook/testing/session_cache.py +50 -0
- captain_hook/testing/types.py +88 -0
- captain_hook/tests/__init__.py +27 -0
- captain_hook/tests/helpers.py +361 -0
- captain_hook/tools.py +59 -0
- captain_hook/transcript/__init__.py +572 -0
- captain_hook/transcript/inputs.py +226 -0
- captain_hook/transcript/models.py +186 -0
- captain_hook/types.py +381 -0
- captain_hook/util/__init__.py +0 -0
- captain_hook/util/model_cache.py +87 -0
- captain_hook/utils.py +27 -0
- 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
|