capt-hook 4.5.0__tar.gz → 5.1.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-4.5.0 → capt_hook-5.1.0}/PKG-INFO +1 -1
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/__init__.py +7 -2
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/app.py +44 -77
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/classifiers/__init__.py +1 -3
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/classifiers/conductor.py +8 -8
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/cli.py +20 -4
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/command.py +27 -30
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/conditions.py +78 -18
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/loader.py +9 -7
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/general/capt-hook.toml +1 -1
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/general/commands.py +12 -9
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/general/docs.py +1 -1
- capt_hook-5.1.0/captain_hook/packs/general/models.py +133 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/general/prompts.py +15 -13
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/general/tasks.py +1 -3
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/go/testing.py +3 -3
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/manager.py +15 -21
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/python/testing.py +3 -3
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/steering/steering.py +21 -1
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/primitives/commands.py +18 -6
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/primitives/lint.py +8 -5
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/primitives/llm.py +9 -8
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/primitives/nudge.py +49 -9
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/primitives/rewrite.py +8 -1
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/primitives/workflow.py +12 -4
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/review/dashboard.py +1 -3
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/review/fix.py +9 -14
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/review/formats.py +4 -2
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/review/judge.py +74 -86
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/review/pipeline.py +9 -8
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/review/scan.py +4 -7
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/review/store.py +126 -130
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/settings.py +8 -8
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +22 -21
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/style/__init__.py +2 -5
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/style/matchers.py +5 -8
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/testing/helpers.py +65 -19
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/testing/types.py +26 -6
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/types.py +189 -38
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/util/http.py +1 -3
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/util/model_cache.py +11 -8
- {capt_hook-4.5.0 → capt_hook-5.1.0}/pyproject.toml +1 -1
- capt_hook-4.5.0/captain_hook/packs/general/models.py +0 -67
- {capt_hook-4.5.0 → capt_hook-5.1.0}/LICENSE +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/README.md +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/.claude-plugin/plugin.json +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/__main__.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/ast_grep.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/classifiers/droid.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/classifiers/native.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/context.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/decisions.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/dispatch.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/durable.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/events.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/fields.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/file.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/log.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/__init__.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/general/__init__.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/general/plans.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/general/review.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/go/__init__.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/go/capt-hook.toml +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/go/toolchain.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/python/__init__.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/python/capt-hook.toml +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/python/style.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/python/toolchain.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/steering/__init__.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/steering/capt-hook.toml +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/primitives/__init__.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/prompt.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/py.typed +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/review/__init__.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/review/cli.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/review/repo.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/review/settings.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/review/sync.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/session.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/signals/__init__.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/signals/nlp.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/skills/authoring-hooks/SKILL.md +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/skills/scanning-sessions/SKILL.md +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/state.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/style/ast_grep.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/style/scope.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/style/types.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/tasks.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/testing/__init__.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/testing/session_cache.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/util/__init__.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/util/fs.py +0 -0
- {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/util/text.py +0 -0
|
@@ -20,8 +20,8 @@ from cc_transcript.tools import (
|
|
|
20
20
|
)
|
|
21
21
|
|
|
22
22
|
from captain_hook import file, style, util
|
|
23
|
-
from captain_hook.app import hook, on
|
|
24
|
-
from captain_hook.command import
|
|
23
|
+
from captain_hook.app import hook, on
|
|
24
|
+
from captain_hook.command import CommandLine, ParsedCommand, Redirect
|
|
25
25
|
from captain_hook.context import HookContext
|
|
26
26
|
from captain_hook.durable import DurableSlot, DurableState, DurableStore
|
|
27
27
|
from captain_hook.events import (
|
|
@@ -73,6 +73,8 @@ from captain_hook.testing import Allow, Block, FileFixture, InlineTests, Input,
|
|
|
73
73
|
from captain_hook.types import (
|
|
74
74
|
Action,
|
|
75
75
|
Agent,
|
|
76
|
+
And,
|
|
77
|
+
Command,
|
|
76
78
|
Content,
|
|
77
79
|
CustomCommandLineCondition,
|
|
78
80
|
CustomCondition,
|
|
@@ -82,9 +84,12 @@ from captain_hook.types import (
|
|
|
82
84
|
HookResponse,
|
|
83
85
|
HookResult,
|
|
84
86
|
InPlanMode,
|
|
87
|
+
Not,
|
|
88
|
+
Or,
|
|
85
89
|
Pattern,
|
|
86
90
|
RanCommand,
|
|
87
91
|
ReadFile,
|
|
92
|
+
Runs,
|
|
88
93
|
Signal,
|
|
89
94
|
Signals,
|
|
90
95
|
SourceEdits,
|
|
@@ -9,12 +9,23 @@ from typing import TYPE_CHECKING, get_args
|
|
|
9
9
|
|
|
10
10
|
from captain_hook.conditions import matches_conditions
|
|
11
11
|
from captain_hook.types import (
|
|
12
|
+
Agent,
|
|
13
|
+
Command,
|
|
14
|
+
Content,
|
|
12
15
|
CustomCondition,
|
|
13
16
|
Event,
|
|
17
|
+
FilePath,
|
|
14
18
|
HookSpec,
|
|
15
19
|
InlineTests,
|
|
20
|
+
Pattern,
|
|
16
21
|
RegisteredHook,
|
|
22
|
+
Runs,
|
|
23
|
+
SourceEdits,
|
|
17
24
|
TCondition,
|
|
25
|
+
TestFile,
|
|
26
|
+
Tool,
|
|
27
|
+
ToolInput,
|
|
28
|
+
WorkflowScript,
|
|
18
29
|
)
|
|
19
30
|
|
|
20
31
|
if TYPE_CHECKING:
|
|
@@ -29,14 +40,38 @@ HookHandler = Callable[["BaseHookEvent"], "HookResult | None"]
|
|
|
29
40
|
VALID_CONDITION_TYPES = tuple(t for t in get_args(TCondition) if t is not CustomCondition)
|
|
30
41
|
VALID_CONDITION_NAMES = ", ".join(t.__name__ for t in VALID_CONDITION_TYPES) + ", or a CustomCondition"
|
|
31
42
|
|
|
32
|
-
|
|
33
|
-
|
|
43
|
+
_TOOL_EVENTS = Event.PreToolUse | Event.PostToolUse | Event.PostToolUseFailure
|
|
44
|
+
|
|
45
|
+
# Conditions that read the current event's tool input can only match on a tool event; a
|
|
46
|
+
# condition absent from this map (transcript-history conditions, InPlanMode, Waiting,
|
|
47
|
+
# combinators, CustomCondition) reads session state and is valid on every event.
|
|
48
|
+
_CONDITION_EVENTS: dict[type, Event] = {
|
|
49
|
+
Tool: _TOOL_EVENTS,
|
|
50
|
+
ToolInput: _TOOL_EVENTS,
|
|
51
|
+
WorkflowScript: _TOOL_EVENTS,
|
|
52
|
+
Command: _TOOL_EVENTS,
|
|
53
|
+
Runs: _TOOL_EVENTS,
|
|
54
|
+
FilePath: _TOOL_EVENTS,
|
|
55
|
+
Content: _TOOL_EVENTS,
|
|
56
|
+
Pattern: _TOOL_EVENTS,
|
|
57
|
+
TestFile: _TOOL_EVENTS,
|
|
58
|
+
SourceEdits: _TOOL_EVENTS,
|
|
59
|
+
Agent: _TOOL_EVENTS | Event.SubagentStart | Event.SubagentStop,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def validate_conditions(conditions: Sequence[TCondition], label: str, events: Event | None = None) -> None:
|
|
34
64
|
for c in conditions:
|
|
35
65
|
if not isinstance(c, (*VALID_CONDITION_TYPES, CustomCondition)):
|
|
36
66
|
raise TypeError(
|
|
37
67
|
f"Invalid condition in {label}: {c!r} (type {type(c).__name__}). "
|
|
38
68
|
f"Expected one of: {VALID_CONDITION_NAMES}."
|
|
39
69
|
)
|
|
70
|
+
if events is not None and (valid := _CONDITION_EVENTS.get(type(c))) is not None and not (events & valid):
|
|
71
|
+
raise TypeError(
|
|
72
|
+
f"{c!r} in {label} can never match on {events!r} — it reads the current tool input, "
|
|
73
|
+
f"which only exists on {valid!r}."
|
|
74
|
+
)
|
|
40
75
|
|
|
41
76
|
|
|
42
77
|
def validate_handler_signature(fn: HookHandler) -> None:
|
|
@@ -70,6 +105,7 @@ class State:
|
|
|
70
105
|
gitignore_patterns: list[str] = field(default_factory=list)
|
|
71
106
|
settings: HooksSettings | None = None
|
|
72
107
|
classifier: UserClassifier | None = None
|
|
108
|
+
load_errors: list[tuple[str, BaseException]] = field(default_factory=list)
|
|
73
109
|
counter: int = field(default=0, repr=False)
|
|
74
110
|
|
|
75
111
|
|
|
@@ -79,6 +115,7 @@ _state = State()
|
|
|
79
115
|
def reset() -> None:
|
|
80
116
|
_state.hooks.clear()
|
|
81
117
|
_state.gitignore_patterns.clear()
|
|
118
|
+
_state.load_errors.clear()
|
|
82
119
|
_state.counter = 0
|
|
83
120
|
_state.settings = None
|
|
84
121
|
_state.classifier = None
|
|
@@ -104,10 +141,10 @@ def is_gitignored(path_str: str) -> bool:
|
|
|
104
141
|
|
|
105
142
|
def hook(
|
|
106
143
|
events: Event,
|
|
144
|
+
message: str,
|
|
107
145
|
*,
|
|
108
146
|
only_if: Sequence[TCondition] = (),
|
|
109
147
|
skip_if: Sequence[TCondition] = (),
|
|
110
|
-
message: str | None = None,
|
|
111
148
|
block: bool = False,
|
|
112
149
|
respect_gitignore: bool = True,
|
|
113
150
|
max_fires: int | None = None,
|
|
@@ -115,13 +152,8 @@ def hook(
|
|
|
115
152
|
async_: bool = False,
|
|
116
153
|
skip_planning_agents: bool = True,
|
|
117
154
|
) -> None:
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
"hook() requires message= for declarative hooks. "
|
|
121
|
-
"Provide message='...' or use @on() for handler-based hooks."
|
|
122
|
-
)
|
|
123
|
-
validate_conditions(only_if, "only_if")
|
|
124
|
-
validate_conditions(skip_if, "skip_if")
|
|
155
|
+
validate_conditions(only_if, "only_if", events)
|
|
156
|
+
validate_conditions(skip_if, "skip_if", events)
|
|
125
157
|
_state.counter += 1
|
|
126
158
|
_state.hooks.append(
|
|
127
159
|
RegisteredHook(
|
|
@@ -153,77 +185,12 @@ def on(
|
|
|
153
185
|
async_: bool = False,
|
|
154
186
|
skip_planning_agents: bool = True,
|
|
155
187
|
) -> Callable[[HookHandler], HookHandler]:
|
|
156
|
-
validate_conditions(only_if, "only_if")
|
|
157
|
-
validate_conditions(skip_if, "skip_if")
|
|
158
|
-
spec = HookSpec(
|
|
159
|
-
events=events,
|
|
160
|
-
only_if=tuple(only_if),
|
|
161
|
-
skip_if=tuple(skip_if),
|
|
162
|
-
respect_gitignore=respect_gitignore,
|
|
163
|
-
max_fires=max_fires,
|
|
164
|
-
tests=tests,
|
|
165
|
-
async_=async_,
|
|
166
|
-
skip_planning_agents=skip_planning_agents,
|
|
167
|
-
)
|
|
168
|
-
|
|
169
|
-
def decorator(fn: HookHandler) -> HookHandler:
|
|
170
|
-
validate_handler_signature(fn)
|
|
171
|
-
_state.hooks.append(
|
|
172
|
-
RegisteredHook(
|
|
173
|
-
spec=spec,
|
|
174
|
-
handler=fn,
|
|
175
|
-
name=fn.__name__,
|
|
176
|
-
source_file=fn.__code__.co_filename,
|
|
177
|
-
)
|
|
178
|
-
)
|
|
179
|
-
return fn
|
|
180
|
-
|
|
181
|
-
return decorator
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
def register(
|
|
185
|
-
events: Event,
|
|
186
|
-
*,
|
|
187
|
-
only_if: Sequence[TCondition] = (),
|
|
188
|
-
skip_if: Sequence[TCondition] = (),
|
|
189
|
-
message: str | None = None,
|
|
190
|
-
block: bool = False,
|
|
191
|
-
respect_gitignore: bool = True,
|
|
192
|
-
max_fires: int | None = None,
|
|
193
|
-
tests: InlineTests | None = None,
|
|
194
|
-
async_: bool = False,
|
|
195
|
-
skip_planning_agents: bool = True,
|
|
196
|
-
) -> Callable[[HookHandler], HookHandler] | None:
|
|
197
|
-
validate_conditions(only_if, "only_if")
|
|
198
|
-
validate_conditions(skip_if, "skip_if")
|
|
199
|
-
|
|
200
|
-
if message is not None:
|
|
201
|
-
hook(
|
|
202
|
-
events,
|
|
203
|
-
only_if=only_if,
|
|
204
|
-
skip_if=skip_if,
|
|
205
|
-
message=message,
|
|
206
|
-
block=block,
|
|
207
|
-
respect_gitignore=respect_gitignore,
|
|
208
|
-
max_fires=max_fires,
|
|
209
|
-
tests=tests,
|
|
210
|
-
async_=async_,
|
|
211
|
-
skip_planning_agents=skip_planning_agents,
|
|
212
|
-
)
|
|
213
|
-
return None
|
|
214
|
-
|
|
215
|
-
if block:
|
|
216
|
-
raise TypeError(
|
|
217
|
-
"hook() called with block=True but no message= provided. "
|
|
218
|
-
"Declarative hooks require message= to specify the block reason. "
|
|
219
|
-
"Either provide message='...' or use @on() for handler-based hooks."
|
|
220
|
-
)
|
|
221
|
-
|
|
188
|
+
validate_conditions(only_if, "only_if", events)
|
|
189
|
+
validate_conditions(skip_if, "skip_if", events)
|
|
222
190
|
spec = HookSpec(
|
|
223
191
|
events=events,
|
|
224
192
|
only_if=tuple(only_if),
|
|
225
193
|
skip_if=tuple(skip_if),
|
|
226
|
-
block=block,
|
|
227
194
|
respect_gitignore=respect_gitignore,
|
|
228
195
|
max_fires=max_fires,
|
|
229
196
|
tests=tests,
|
|
@@ -9,8 +9,6 @@ if TYPE_CHECKING:
|
|
|
9
9
|
from cc_transcript.activity import UserClassifier
|
|
10
10
|
from cc_transcript.models import TranscriptEvent
|
|
11
11
|
|
|
12
|
-
CLASSIFIER_MODULES = ("droid", "conductor", "native")
|
|
13
|
-
|
|
14
12
|
|
|
15
13
|
def detect(
|
|
16
14
|
cwd: str | None = None,
|
|
@@ -20,7 +18,7 @@ def detect(
|
|
|
20
18
|
"""Auto-detect the environment and return the user classifier for turn segmentation."""
|
|
21
19
|
return next(
|
|
22
20
|
mod.classifier
|
|
23
|
-
for name in
|
|
21
|
+
for name in ("droid", "conductor", "native")
|
|
24
22
|
if (mod := importlib.import_module(f".{name}", __package__))
|
|
25
23
|
and mod.detect(cwd=cwd, transcript_path=transcript_path, events=events)
|
|
26
24
|
)
|
|
@@ -10,16 +10,16 @@ if TYPE_CHECKING:
|
|
|
10
10
|
|
|
11
11
|
from cc_transcript.models import TranscriptEvent
|
|
12
12
|
|
|
13
|
-
SYNTHETIC_PREFIXES = (
|
|
14
|
-
"<system_instruction>",
|
|
15
|
-
"<task-notification>",
|
|
16
|
-
"<local-command-caveat>",
|
|
17
|
-
"<command-name>",
|
|
18
|
-
)
|
|
19
|
-
|
|
20
13
|
|
|
21
14
|
def classifier(event: UserEvent) -> bool:
|
|
22
|
-
return native_user_classifier(event) and not event.text.strip().startswith(
|
|
15
|
+
return native_user_classifier(event) and not event.text.strip().startswith(
|
|
16
|
+
(
|
|
17
|
+
"<system_instruction>",
|
|
18
|
+
"<task-notification>",
|
|
19
|
+
"<local-command-caveat>",
|
|
20
|
+
"<command-name>",
|
|
21
|
+
)
|
|
22
|
+
)
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
def detect(
|
|
@@ -27,7 +27,6 @@ from captain_hook.types import Event
|
|
|
27
27
|
|
|
28
28
|
DIST_NAME = "capt-hook"
|
|
29
29
|
EVENT_NAMES = ", ".join(n for e in Event if (n := e.name))
|
|
30
|
-
MARKETPLACE = {"captain-hook": {"source": {"source": "github", "repo": "yasyf/captain-hook"}}}
|
|
31
30
|
PLUGIN_ID = "captain-hook@captain-hook"
|
|
32
31
|
|
|
33
32
|
|
|
@@ -79,7 +78,8 @@ def register_marketplace(root: Path) -> None:
|
|
|
79
78
|
settings_path,
|
|
80
79
|
existing
|
|
81
80
|
| {
|
|
82
|
-
"extraKnownMarketplaces": existing.get("extraKnownMarketplaces", {})
|
|
81
|
+
"extraKnownMarketplaces": existing.get("extraKnownMarketplaces", {})
|
|
82
|
+
| {"captain-hook": {"source": {"source": "github", "repo": "yasyf/captain-hook"}}},
|
|
83
83
|
"enabledPlugins": existing.get("enabledPlugins", {}) | {PLUGIN_ID: True},
|
|
84
84
|
},
|
|
85
85
|
)
|
|
@@ -402,10 +402,23 @@ def expected_kinds_from_state() -> dict[str, str]:
|
|
|
402
402
|
|
|
403
403
|
|
|
404
404
|
def run_tests(json_output: bool = False) -> None:
|
|
405
|
+
from captain_hook.app import _state
|
|
405
406
|
from captain_hook.testing.helpers import run_inline_tests
|
|
406
407
|
|
|
408
|
+
load_errors = list(_state.load_errors)
|
|
409
|
+
for source, exc in load_errors:
|
|
410
|
+
line = f"{type(exc).__name__}: {exc}"
|
|
411
|
+
if json_output:
|
|
412
|
+
print(json.dumps({"id": source, "status": "load_error", "reason": line}))
|
|
413
|
+
else:
|
|
414
|
+
print(f" ERROR {source} failed to import: {line}")
|
|
415
|
+
|
|
407
416
|
results = run_inline_tests()
|
|
408
417
|
if not results:
|
|
418
|
+
if load_errors:
|
|
419
|
+
if not json_output:
|
|
420
|
+
print(f"\n{len(load_errors)} hook file(s) failed to import.")
|
|
421
|
+
sys.exit(1)
|
|
409
422
|
if json_output:
|
|
410
423
|
print(json.dumps({"status": "empty", "reason": "no inline tests"}))
|
|
411
424
|
else:
|
|
@@ -448,8 +461,11 @@ def run_tests(json_output: bool = False) -> None:
|
|
|
448
461
|
|
|
449
462
|
if not json_output:
|
|
450
463
|
total = passed + failed + errors + skipped
|
|
451
|
-
|
|
452
|
-
|
|
464
|
+
summary = f"\n{total} tests: {passed} passed, {failed} failed, {errors} errors, {skipped} skipped"
|
|
465
|
+
if load_errors:
|
|
466
|
+
summary += f", {len(load_errors)} import errors"
|
|
467
|
+
print(summary)
|
|
468
|
+
if failed or errors or load_errors:
|
|
453
469
|
sys.exit(1)
|
|
454
470
|
|
|
455
471
|
|
|
@@ -12,10 +12,7 @@ from tree_sitter import Language, Node, Parser
|
|
|
12
12
|
if TYPE_CHECKING:
|
|
13
13
|
from collections.abc import Callable, Iterator
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
BASH_PARSER = Parser(BASH_LANGUAGE)
|
|
17
|
-
|
|
18
|
-
COMPOUND_OPS = frozenset({"&&", "||", ";", "|", "&"})
|
|
15
|
+
BASH_PARSER = Parser(Language(tsbash.language())) # pyright: ignore[reportDeprecated]
|
|
19
16
|
|
|
20
17
|
|
|
21
18
|
@dataclass(frozen=True)
|
|
@@ -28,10 +25,10 @@ class Redirect:
|
|
|
28
25
|
|
|
29
26
|
|
|
30
27
|
@dataclass(frozen=True)
|
|
31
|
-
class
|
|
28
|
+
class ParsedCommand:
|
|
32
29
|
"""A single parsed shell command with executable, arguments, env vars, and redirects.
|
|
33
30
|
|
|
34
|
-
Use ``
|
|
31
|
+
Use ``ParsedCommand.parse(raw)`` to parse a command string, or access via ``CommandLine``.
|
|
35
32
|
"""
|
|
36
33
|
|
|
37
34
|
raw: str
|
|
@@ -41,11 +38,11 @@ class Command:
|
|
|
41
38
|
redirects: tuple[Redirect, ...] = ()
|
|
42
39
|
|
|
43
40
|
@classmethod
|
|
44
|
-
def parse(cls, raw: str) ->
|
|
41
|
+
def parse(cls, raw: str) -> ParsedCommand:
|
|
45
42
|
return CommandLine.parse(raw).primary
|
|
46
43
|
|
|
47
44
|
@classmethod
|
|
48
|
-
def empty(cls) ->
|
|
45
|
+
def empty(cls) -> ParsedCommand:
|
|
49
46
|
return cls(raw="", executable="", args=())
|
|
50
47
|
|
|
51
48
|
@cached_property
|
|
@@ -89,7 +86,7 @@ class CommandLine:
|
|
|
89
86
|
"""
|
|
90
87
|
|
|
91
88
|
raw: str
|
|
92
|
-
parts: tuple[tuple[
|
|
89
|
+
parts: tuple[tuple[ParsedCommand, str | None], ...]
|
|
93
90
|
|
|
94
91
|
@classmethod
|
|
95
92
|
def parse(cls, raw: str) -> CommandLine:
|
|
@@ -100,18 +97,18 @@ class CommandLine:
|
|
|
100
97
|
return cls(raw=raw, parts=((cls.fallback(raw), None),))
|
|
101
98
|
|
|
102
99
|
@cached_property
|
|
103
|
-
def commands(self) -> tuple[
|
|
100
|
+
def commands(self) -> tuple[ParsedCommand, ...]:
|
|
104
101
|
return tuple(cmd for cmd, _ in self.parts)
|
|
105
102
|
|
|
106
103
|
@cached_property
|
|
107
|
-
def primary(self) ->
|
|
108
|
-
return self.parts[-1][0] if self.parts else
|
|
104
|
+
def primary(self) -> ParsedCommand:
|
|
105
|
+
return self.parts[-1][0] if self.parts else ParsedCommand.empty()
|
|
109
106
|
|
|
110
107
|
@cached_property
|
|
111
|
-
def head(self) ->
|
|
112
|
-
return self.parts[0][0] if self.parts else
|
|
108
|
+
def head(self) -> ParsedCommand:
|
|
109
|
+
return self.parts[0][0] if self.parts else ParsedCommand.empty()
|
|
113
110
|
|
|
114
|
-
def __iter__(self) -> Iterator[
|
|
111
|
+
def __iter__(self) -> Iterator[ParsedCommand]:
|
|
115
112
|
return iter(self.commands)
|
|
116
113
|
|
|
117
114
|
def __len__(self) -> int:
|
|
@@ -197,7 +194,7 @@ class CommandLine:
|
|
|
197
194
|
return Redirect(op=op, target=target, fd=fd)
|
|
198
195
|
|
|
199
196
|
@staticmethod
|
|
200
|
-
def extract_command(node: Node) ->
|
|
197
|
+
def extract_command(node: Node) -> ParsedCommand:
|
|
201
198
|
executable = ""
|
|
202
199
|
args: list[str] = []
|
|
203
200
|
env: list[tuple[str, str]] = []
|
|
@@ -235,7 +232,7 @@ class CommandLine:
|
|
|
235
232
|
case _:
|
|
236
233
|
pass
|
|
237
234
|
|
|
238
|
-
return
|
|
235
|
+
return ParsedCommand(
|
|
239
236
|
raw=CommandLine.node_text(node),
|
|
240
237
|
executable=executable,
|
|
241
238
|
args=tuple(args),
|
|
@@ -244,8 +241,8 @@ class CommandLine:
|
|
|
244
241
|
)
|
|
245
242
|
|
|
246
243
|
@staticmethod
|
|
247
|
-
def collect_parts(children: list[Node], ops: frozenset[str]) -> list[tuple[
|
|
248
|
-
parts: list[tuple[
|
|
244
|
+
def collect_parts(children: list[Node], ops: frozenset[str]) -> list[tuple[ParsedCommand, str | None]]:
|
|
245
|
+
parts: list[tuple[ParsedCommand, str | None]] = []
|
|
249
246
|
for child in children:
|
|
250
247
|
text = CommandLine.node_text(child)
|
|
251
248
|
if child.type in ops or text in ops:
|
|
@@ -258,9 +255,9 @@ class CommandLine:
|
|
|
258
255
|
return parts
|
|
259
256
|
|
|
260
257
|
@staticmethod
|
|
261
|
-
def walk_redirected(node: Node) -> list[tuple[
|
|
258
|
+
def walk_redirected(node: Node) -> list[tuple[ParsedCommand, str | None]]:
|
|
262
259
|
redirects: list[Redirect] = []
|
|
263
|
-
inner_parts: list[tuple[
|
|
260
|
+
inner_parts: list[tuple[ParsedCommand, str | None]] = []
|
|
264
261
|
for child in node.children:
|
|
265
262
|
if child.type == "file_redirect":
|
|
266
263
|
redirects.append(CommandLine.extract_redirect(child))
|
|
@@ -269,7 +266,7 @@ class CommandLine:
|
|
|
269
266
|
if redirects and inner_parts:
|
|
270
267
|
inner_parts = [
|
|
271
268
|
(
|
|
272
|
-
|
|
269
|
+
ParsedCommand(
|
|
273
270
|
raw=cmd.raw,
|
|
274
271
|
executable=cmd.executable,
|
|
275
272
|
args=cmd.args,
|
|
@@ -282,7 +279,7 @@ class CommandLine:
|
|
|
282
279
|
]
|
|
283
280
|
return inner_parts or [
|
|
284
281
|
(
|
|
285
|
-
|
|
282
|
+
ParsedCommand(
|
|
286
283
|
raw=CommandLine.node_text(node),
|
|
287
284
|
executable="",
|
|
288
285
|
args=(),
|
|
@@ -293,12 +290,12 @@ class CommandLine:
|
|
|
293
290
|
]
|
|
294
291
|
|
|
295
292
|
@staticmethod
|
|
296
|
-
def walk_node(node: Node) -> list[tuple[
|
|
293
|
+
def walk_node(node: Node) -> list[tuple[ParsedCommand, str | None]]:
|
|
297
294
|
match node.type:
|
|
298
295
|
case "program":
|
|
299
296
|
return CommandLine.collect_parts(node.children, frozenset({";"}))
|
|
300
297
|
case "list":
|
|
301
|
-
return CommandLine.collect_parts(node.children,
|
|
298
|
+
return CommandLine.collect_parts(node.children, frozenset({"&&", "||", ";", "|", "&"}))
|
|
302
299
|
case "pipeline":
|
|
303
300
|
return CommandLine.collect_parts(node.children, frozenset({"|"}))
|
|
304
301
|
case "command":
|
|
@@ -306,14 +303,14 @@ class CommandLine:
|
|
|
306
303
|
case "redirected_statement":
|
|
307
304
|
return CommandLine.walk_redirected(node)
|
|
308
305
|
case _:
|
|
309
|
-
parts: list[tuple[
|
|
306
|
+
parts: list[tuple[ParsedCommand, str | None]] = []
|
|
310
307
|
for child in node.children:
|
|
311
308
|
parts.extend(CommandLine.walk_node(child))
|
|
312
309
|
return parts
|
|
313
310
|
|
|
314
311
|
@staticmethod
|
|
315
|
-
def fallback(raw: str) ->
|
|
316
|
-
return
|
|
312
|
+
def fallback(raw: str) -> ParsedCommand:
|
|
313
|
+
return ParsedCommand(raw=raw, executable=raw.split()[0] if raw.split() else raw, args=())
|
|
317
314
|
|
|
318
315
|
|
|
319
316
|
@dataclass(frozen=True)
|
|
@@ -350,11 +347,11 @@ class CommandLineQuery:
|
|
|
350
347
|
"""
|
|
351
348
|
return any(name in cmd.args for cmd in self.line.commands)
|
|
352
349
|
|
|
353
|
-
def any_command(self, pred: Callable[[
|
|
350
|
+
def any_command(self, pred: Callable[[ParsedCommand], bool]) -> bool:
|
|
354
351
|
"""Return whether any command in the line satisfies ``pred``.
|
|
355
352
|
|
|
356
353
|
Args:
|
|
357
|
-
pred: Predicate applied to each parsed ``
|
|
354
|
+
pred: Predicate applied to each parsed ``ParsedCommand``.
|
|
358
355
|
|
|
359
356
|
Returns:
|
|
360
357
|
``True`` if ``pred`` returns truthy for at least one command.
|
|
@@ -7,19 +7,22 @@ from pathlib import Path
|
|
|
7
7
|
from typing import TYPE_CHECKING
|
|
8
8
|
|
|
9
9
|
from cc_transcript.models import OtherEvent
|
|
10
|
-
from cc_transcript.tools import WorkflowCall, tool_name_matches
|
|
10
|
+
from cc_transcript.tools import SkillCall, WorkflowCall, tool_name_matches
|
|
11
11
|
|
|
12
12
|
from captain_hook.types import (
|
|
13
13
|
Agent,
|
|
14
|
+
And,
|
|
14
15
|
Command,
|
|
15
16
|
Content,
|
|
16
17
|
CustomCondition,
|
|
17
18
|
FilePath,
|
|
18
19
|
InPlanMode,
|
|
20
|
+
Not,
|
|
19
21
|
Or,
|
|
20
22
|
Pattern,
|
|
21
23
|
RanCommand,
|
|
22
24
|
ReadFile,
|
|
25
|
+
Runs,
|
|
23
26
|
SourceEdits,
|
|
24
27
|
TCondition,
|
|
25
28
|
TestFile,
|
|
@@ -99,16 +102,65 @@ def workflow_script_source(evt: BaseHookEvent) -> str | None:
|
|
|
99
102
|
return None
|
|
100
103
|
|
|
101
104
|
|
|
105
|
+
_WF_OPT_VALUE = r"""(?:'([^'\n]*)'|"([^"\n]*)"|`([^`\n]*)`|([$\w][\w.$]*))"""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def workflow_opt_values(source: str, key: str) -> list[str]:
|
|
109
|
+
"""Best-effort raw-source scan for values pinned to `key` in agent() opts. Never raises.
|
|
110
|
+
|
|
111
|
+
Values are single-line: the whitespace around the colon is horizontal only, so a valueless
|
|
112
|
+
``key:`` at end of line does not swallow the next line's token.
|
|
113
|
+
"""
|
|
114
|
+
return [
|
|
115
|
+
next(g for g in m.groups() if g is not None)
|
|
116
|
+
for m in re.finditer(rf"\b{re.escape(key)}[ \t]*:[ \t]*{_WF_OPT_VALUE}", source)
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
|
|
102
120
|
def matches_workflow_script(cond: WorkflowScript, evt: BaseHookEvent) -> bool:
|
|
103
121
|
if (source := workflow_script_source(evt)) is None:
|
|
104
122
|
return False
|
|
105
|
-
if cond.pattern is not None:
|
|
106
|
-
return
|
|
107
|
-
return
|
|
108
|
-
re.search(
|
|
123
|
+
if cond.pattern is not None and not re.search(cond.pattern, source, re.MULTILINE):
|
|
124
|
+
return False
|
|
125
|
+
return all(
|
|
126
|
+
any(re.search(pattern, value) for value in workflow_opt_values(source, key)) for key, pattern in cond.opts
|
|
109
127
|
)
|
|
110
128
|
|
|
111
129
|
|
|
130
|
+
def coerce_tool_input(value: object) -> str | None:
|
|
131
|
+
"""Coerce a scalar raw tool-input value to matchable text; None for non-scalars."""
|
|
132
|
+
match value:
|
|
133
|
+
case str():
|
|
134
|
+
return value
|
|
135
|
+
case bool():
|
|
136
|
+
return "true" if value else "false"
|
|
137
|
+
case int() | float():
|
|
138
|
+
return str(value)
|
|
139
|
+
case _:
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def has_read_glob(t: Session, *globs: str, subagents: bool = True) -> bool:
|
|
144
|
+
from cc_transcript.query import sidechain_sessions
|
|
145
|
+
|
|
146
|
+
return any(f.matches(*globs) for f in t.tool_calls.named("Read").files()) or (
|
|
147
|
+
subagents and any(has_read_glob(sub, *globs) for sub in sidechain_sessions(t.path))
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def skill_name_matches(skill: str, names: tuple[str, ...]) -> bool:
|
|
152
|
+
return skill in names or skill.split(":", 1)[-1] in names
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def has_used_skill(t: Session, names: tuple[str, ...], *, subagents: bool = True) -> bool:
|
|
156
|
+
from cc_transcript.query import sidechain_sessions
|
|
157
|
+
|
|
158
|
+
return any(
|
|
159
|
+
isinstance(call := use.call, SkillCall) and skill_name_matches(call.skill, names)
|
|
160
|
+
for use in t.tool_calls.named("Skill")
|
|
161
|
+
) or (subagents and any(has_used_skill(sub, names) for sub in sidechain_sessions(t.path)))
|
|
162
|
+
|
|
163
|
+
|
|
112
164
|
def is_project_file(evt: BaseHookEvent) -> bool:
|
|
113
165
|
if not (file := evt.file):
|
|
114
166
|
return False
|
|
@@ -121,23 +173,25 @@ def is_project_file(evt: BaseHookEvent) -> bool:
|
|
|
121
173
|
|
|
122
174
|
def check_condition(c: TCondition, evt: BaseHookEvent) -> bool:
|
|
123
175
|
match c:
|
|
124
|
-
case Tool(
|
|
125
|
-
return bool(evt.tool_name) and tool_name_matches(evt.tool_name,
|
|
176
|
+
case Tool(names):
|
|
177
|
+
return bool(evt.tool_name) and tool_name_matches(evt.tool_name, "|".join(names))
|
|
126
178
|
case FilePath(patterns, project_only):
|
|
127
179
|
return bool(evt.file and (not project_only or is_project_file(evt)) and evt.file.matches(*patterns))
|
|
128
180
|
case Command(pattern):
|
|
129
|
-
return bool(
|
|
181
|
+
return bool(
|
|
182
|
+
(cl := evt.command_line) and any(re.search(pattern, s) for s in (cl.raw, *map(str, cl.commands)))
|
|
183
|
+
)
|
|
130
184
|
case Content(pattern, project_only):
|
|
131
185
|
return bool(
|
|
132
186
|
(not project_only or is_project_file(evt))
|
|
133
187
|
and evt.content
|
|
134
188
|
and re.search(pattern, evt.content, re.MULTILINE)
|
|
135
189
|
)
|
|
136
|
-
case ToolInput(
|
|
137
|
-
return bool(
|
|
138
|
-
evt.
|
|
139
|
-
and isinstance(value := evt.input.raw.get(field), str)
|
|
190
|
+
case ToolInput(fields):
|
|
191
|
+
return bool(evt.tool_name) and all(
|
|
192
|
+
(value := coerce_tool_input(evt.input.raw.get(field))) is not None
|
|
140
193
|
and re.search(pattern, value, re.MULTILINE)
|
|
194
|
+
for field, pattern in fields
|
|
141
195
|
)
|
|
142
196
|
case WorkflowScript() as workflow_script:
|
|
143
197
|
return matches_workflow_script(workflow_script, evt)
|
|
@@ -149,8 +203,8 @@ def check_condition(c: TCondition, evt: BaseHookEvent) -> bool:
|
|
|
149
203
|
return False
|
|
150
204
|
resolved = lang or (lang_for_path(evt.file.path) if evt.file else None)
|
|
151
205
|
return bool(resolved and ast_grep_matches(evt.content, resolved, pattern))
|
|
152
|
-
case Agent(
|
|
153
|
-
return bool(evt.agent_type) and evt.agent_type in
|
|
206
|
+
case Agent(names):
|
|
207
|
+
return bool(evt.agent_type) and evt.agent_type in names
|
|
154
208
|
case TestFile(project_only):
|
|
155
209
|
return bool(evt.file and (not project_only or is_project_file(evt)) and evt.file.is_test)
|
|
156
210
|
case SourceEdits(lang, include_tests, paths):
|
|
@@ -160,16 +214,18 @@ def check_condition(c: TCondition, evt: BaseHookEvent) -> bool:
|
|
|
160
214
|
and (f := evt.file)
|
|
161
215
|
and f.matches(*SourceEdits(lang=lang).globs)
|
|
162
216
|
and (include_tests or not f.is_test)
|
|
163
|
-
and (paths
|
|
217
|
+
and (not paths or f.matches(*paths))
|
|
164
218
|
)
|
|
165
|
-
case UsedSkill(
|
|
166
|
-
return evt.ctx.transcript
|
|
219
|
+
case UsedSkill(names, subagents):
|
|
220
|
+
return has_used_skill(evt.ctx.transcript, names, subagents=subagents)
|
|
167
221
|
case ReadFile(patterns, subagents):
|
|
168
|
-
return
|
|
222
|
+
return has_read_glob(evt.ctx.transcript, *patterns, subagents=subagents)
|
|
169
223
|
case TouchedFile(patterns, subagents):
|
|
170
224
|
return evt.ctx.transcript.has_edit_to(*patterns, subagents=subagents)
|
|
171
225
|
case RanCommand(pattern, subagents):
|
|
172
226
|
return evt.ctx.transcript.has_command(pattern, subagents=subagents)
|
|
227
|
+
case Runs(argv):
|
|
228
|
+
return bool(argv and (cl := evt.command_line) and any(cmd.argv[: len(argv)] == argv for cmd in cl.commands))
|
|
173
229
|
case InPlanMode():
|
|
174
230
|
return evt.permission_mode == "plan" or (
|
|
175
231
|
(t := evt.ctx.transcript).tool_calls.named("EnterPlanMode").count()
|
|
@@ -177,6 +233,10 @@ def check_condition(c: TCondition, evt: BaseHookEvent) -> bool:
|
|
|
177
233
|
)
|
|
178
234
|
case Waiting():
|
|
179
235
|
return is_waiting(evt)
|
|
236
|
+
case And(conditions):
|
|
237
|
+
return all(check_condition(sub, evt) for sub in conditions)
|
|
238
|
+
case Not(condition):
|
|
239
|
+
return not check_condition(condition, evt)
|
|
180
240
|
case Or(conditions):
|
|
181
241
|
return any(check_condition(sub, evt) for sub in conditions)
|
|
182
242
|
case CustomCondition():
|