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
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Condition evaluation: checks ``TCondition`` instances against the current event."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from captain_hook.types import (
|
|
8
|
+
Agent,
|
|
9
|
+
Command,
|
|
10
|
+
Content,
|
|
11
|
+
CustomCondition,
|
|
12
|
+
FilePath,
|
|
13
|
+
InPlanMode,
|
|
14
|
+
Or,
|
|
15
|
+
RanCommand,
|
|
16
|
+
ReadFile,
|
|
17
|
+
SourceEdits,
|
|
18
|
+
TCondition,
|
|
19
|
+
TestFile,
|
|
20
|
+
Tool,
|
|
21
|
+
TouchedFile,
|
|
22
|
+
UsedSkill,
|
|
23
|
+
Waiting,
|
|
24
|
+
tool_name_matches,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from captain_hook.events import BaseHookEvent
|
|
29
|
+
from captain_hook.transcript import Transcript
|
|
30
|
+
from captain_hook.transcript.models import ToolUse
|
|
31
|
+
from captain_hook.types import HookSpec
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def has_completion_notification(t: Transcript, tool_use_id: str, after_idx: int) -> bool:
|
|
35
|
+
return any(
|
|
36
|
+
(n := m.notification) and n.tool_use_id == tool_use_id
|
|
37
|
+
for m in t.messages[after_idx + 1 :]
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def waiting_tool_names(evt: BaseHookEvent) -> set[str]:
|
|
42
|
+
from captain_hook.settings import DEFAULT_WAITING_TOOLS
|
|
43
|
+
|
|
44
|
+
custom: object = getattr(evt.ctx.settings, "waiting_tools", None)
|
|
45
|
+
return {str(x) for x in custom} if isinstance(custom, list) else set(DEFAULT_WAITING_TOOLS)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def tool_use_waiting(tu: ToolUse, t: Transcript, waiting_names: set[str]) -> bool:
|
|
49
|
+
if tu.name in waiting_names:
|
|
50
|
+
return True
|
|
51
|
+
match tu.name:
|
|
52
|
+
case "Agent" | "Task" | "Bash" if tu.raw_input.get("run_in_background"):
|
|
53
|
+
return True
|
|
54
|
+
case "Agent" | "Task" if "subagent_type" not in tu.raw_input:
|
|
55
|
+
return True
|
|
56
|
+
case "Agent" | "Task" if tu.result and tu.result.is_async:
|
|
57
|
+
return not has_completion_notification(t, tu.id, tu.message_index)
|
|
58
|
+
case "Workflow":
|
|
59
|
+
return not has_completion_notification(t, tu.id, tu.message_index)
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def is_waiting(evt: BaseHookEvent) -> bool:
|
|
64
|
+
if not (t := evt.ctx.transcript):
|
|
65
|
+
return False
|
|
66
|
+
waiting_names = waiting_tool_names(evt)
|
|
67
|
+
return any(tool_use_waiting(tu, t.current_turn, waiting_names) for tu in t.current_turn.tool_uses)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def is_project_file(evt: BaseHookEvent) -> bool:
|
|
71
|
+
if not (file := evt.file):
|
|
72
|
+
return False
|
|
73
|
+
if not file.path.is_absolute():
|
|
74
|
+
return True
|
|
75
|
+
if not (root := evt.ctx.repo_root):
|
|
76
|
+
return True
|
|
77
|
+
return file.path.resolve().is_relative_to(root.resolve())
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def check_condition(c: TCondition, evt: BaseHookEvent) -> bool:
|
|
81
|
+
match c:
|
|
82
|
+
case Tool(pattern):
|
|
83
|
+
if not evt.tool_name:
|
|
84
|
+
return False
|
|
85
|
+
return any(tool_name_matches(evt.tool_name, p) for p in pattern.split("|"))
|
|
86
|
+
case FilePath(patterns, project_only):
|
|
87
|
+
return bool(evt.file and (not project_only or is_project_file(evt)) and evt.file.matches(*patterns))
|
|
88
|
+
case Command(pattern):
|
|
89
|
+
return bool((cl := evt.command_line) and any(re.search(pattern, str(cmd)) for cmd in cl.commands))
|
|
90
|
+
case Content(pattern, project_only):
|
|
91
|
+
return bool(
|
|
92
|
+
(not project_only or is_project_file(evt))
|
|
93
|
+
and evt.content
|
|
94
|
+
and re.search(pattern, evt.content, re.MULTILINE)
|
|
95
|
+
)
|
|
96
|
+
case Agent(name):
|
|
97
|
+
return bool(evt.agent_type) and evt.agent_type in name.split("|")
|
|
98
|
+
case TestFile(project_only):
|
|
99
|
+
return bool(evt.file and (not project_only or is_project_file(evt)) and evt.file.is_test)
|
|
100
|
+
case SourceEdits(lang, include_tests, paths):
|
|
101
|
+
return bool(
|
|
102
|
+
evt.tool_name
|
|
103
|
+
and any(tool_name_matches(evt.tool_name, n) for n in ("Edit", "Write"))
|
|
104
|
+
and (f := evt.file)
|
|
105
|
+
and f.matches(*SourceEdits(lang=lang).globs)
|
|
106
|
+
and (include_tests or not f.is_test)
|
|
107
|
+
and (paths is None or f.matches(paths))
|
|
108
|
+
)
|
|
109
|
+
case UsedSkill(name, subagents):
|
|
110
|
+
return bool(evt.ctx.transcript) and evt.ctx.transcript.has_skill(*name.split("|"), subagents=subagents)
|
|
111
|
+
case ReadFile(patterns, subagents):
|
|
112
|
+
return bool(evt.ctx.transcript) and any(evt.ctx.transcript.has_read(p, subagents=subagents) for p in patterns)
|
|
113
|
+
case TouchedFile(patterns, subagents):
|
|
114
|
+
return bool(evt.ctx.transcript) and evt.ctx.transcript.has_edit_to(*patterns, subagents=subagents)
|
|
115
|
+
case RanCommand(pattern, subagents):
|
|
116
|
+
return bool(evt.ctx.transcript) and evt.ctx.transcript.has_command(pattern, subagents=subagents)
|
|
117
|
+
case InPlanMode():
|
|
118
|
+
return evt.permission_mode == "plan" or (
|
|
119
|
+
bool(t := evt.ctx.transcript)
|
|
120
|
+
and t.count_tools("EnterPlanMode") > t.count_tools("ExitPlanMode")
|
|
121
|
+
)
|
|
122
|
+
case Waiting():
|
|
123
|
+
return is_waiting(evt)
|
|
124
|
+
case Or(conditions):
|
|
125
|
+
return any(check_condition(sub, evt) for sub in conditions)
|
|
126
|
+
case CustomCondition():
|
|
127
|
+
return c.check(evt)
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def matches_conditions(spec: HookSpec, evt: BaseHookEvent) -> bool:
|
|
132
|
+
if any(not check_condition(c, evt) for c in spec.only_if):
|
|
133
|
+
return False
|
|
134
|
+
if any(check_condition(c, evt) for c in spec.skip_if):
|
|
135
|
+
return False
|
|
136
|
+
return True
|
captain_hook/context.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import tempfile
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from functools import cached_property
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
from pydantic_settings import BaseSettings
|
|
14
|
+
|
|
15
|
+
from captain_hook.llm import CodexBackend, LlmBackend, LlmBackends, TModel, TSpecialty
|
|
16
|
+
from captain_hook.prompt import PromptMessage
|
|
17
|
+
from captain_hook.session import SessionStore
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from captain_hook.transcript import Transcript, TranscriptSlice, Turn
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class HookContext:
|
|
25
|
+
"""Runtime context injected into every hook event, providing session state, transcript, settings, and LLM/CLI helpers."""
|
|
26
|
+
|
|
27
|
+
session: SessionStore
|
|
28
|
+
transcript: Transcript
|
|
29
|
+
settings: BaseSettings | None
|
|
30
|
+
project_root: Path | None = None
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def t(self) -> Transcript:
|
|
34
|
+
"""Alias for ``transcript``."""
|
|
35
|
+
return self.transcript
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def s(self) -> SessionStore:
|
|
39
|
+
"""Alias for ``session``."""
|
|
40
|
+
return self.session
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def state(self) -> SessionStore:
|
|
44
|
+
"""Alias for ``session``."""
|
|
45
|
+
return self.session
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def conf(self) -> BaseSettings | None:
|
|
49
|
+
"""Alias for ``settings``."""
|
|
50
|
+
return self.settings
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def c(self) -> BaseSettings | None:
|
|
54
|
+
"""Alias for ``settings`` (shortest form)."""
|
|
55
|
+
return self.conf
|
|
56
|
+
|
|
57
|
+
@cached_property
|
|
58
|
+
def turn(self) -> Turn:
|
|
59
|
+
"""The current transcript turn (cached)."""
|
|
60
|
+
return self.transcript.current_turn
|
|
61
|
+
|
|
62
|
+
@cached_property
|
|
63
|
+
def prior(self) -> TranscriptSlice:
|
|
64
|
+
"""Transcript slice before the current turn (cached)."""
|
|
65
|
+
return self.transcript.prior()
|
|
66
|
+
|
|
67
|
+
def call_cli(
|
|
68
|
+
self,
|
|
69
|
+
args: list[str],
|
|
70
|
+
*,
|
|
71
|
+
input: str | None = None,
|
|
72
|
+
timeout: int = 30,
|
|
73
|
+
env: dict[str, str] | None = None,
|
|
74
|
+
) -> str:
|
|
75
|
+
result = subprocess.run(
|
|
76
|
+
args,
|
|
77
|
+
input=input,
|
|
78
|
+
capture_output=True,
|
|
79
|
+
text=True,
|
|
80
|
+
timeout=timeout,
|
|
81
|
+
env=os.environ | (env or {}),
|
|
82
|
+
cwd=os.environ.get("CLAUDE_PROJECT_DIR") or os.environ.get("FACTORY_PROJECT_DIR"),
|
|
83
|
+
)
|
|
84
|
+
if result.returncode != 0:
|
|
85
|
+
err = subprocess.CalledProcessError(
|
|
86
|
+
result.returncode,
|
|
87
|
+
args,
|
|
88
|
+
output=result.stdout,
|
|
89
|
+
stderr=result.stderr,
|
|
90
|
+
)
|
|
91
|
+
err.add_note(f"argv: {args}")
|
|
92
|
+
err.add_note(f"exit_code: {result.returncode}")
|
|
93
|
+
err.add_note(f"stderr: {result.stderr[-4096:]}")
|
|
94
|
+
err.add_note(f"stdout: {result.stdout[-4096:]}")
|
|
95
|
+
raise err
|
|
96
|
+
return result.stdout
|
|
97
|
+
|
|
98
|
+
def git(self, *args: str) -> str | None:
|
|
99
|
+
try:
|
|
100
|
+
return self.call_cli(["git", *args], timeout=5)
|
|
101
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
@cached_property
|
|
105
|
+
def changed_paths(self) -> frozenset[Path] | None:
|
|
106
|
+
if (out := self.git("diff", "--name-only", "HEAD", "--no-renames")) is None or (root := self.repo_root) is None:
|
|
107
|
+
return None
|
|
108
|
+
return frozenset((root / line).resolve() for line in out.splitlines() if line)
|
|
109
|
+
|
|
110
|
+
@cached_property
|
|
111
|
+
def repo_root(self) -> Path | None:
|
|
112
|
+
if self.project_root is not None:
|
|
113
|
+
return self.project_root.resolve()
|
|
114
|
+
return Path(out.strip()) if (out := self.git("rev-parse", "--show-toplevel")) else None
|
|
115
|
+
|
|
116
|
+
@cached_property
|
|
117
|
+
def current_branch(self) -> str | None:
|
|
118
|
+
return out.strip() if (out := self.git("symbolic-ref", "--short", "HEAD")) else None
|
|
119
|
+
|
|
120
|
+
def call_llm(
|
|
121
|
+
self,
|
|
122
|
+
template: str | PromptMessage,
|
|
123
|
+
*args: Any,
|
|
124
|
+
specialty: TSpecialty = "general",
|
|
125
|
+
model: TModel = "small",
|
|
126
|
+
timeout: int = 180,
|
|
127
|
+
transcript: bool = False,
|
|
128
|
+
agent: bool = False,
|
|
129
|
+
response_model: type[BaseModel] | None = None,
|
|
130
|
+
**kwargs: Any,
|
|
131
|
+
) -> str | BaseModel:
|
|
132
|
+
if isinstance(template, PromptMessage):
|
|
133
|
+
prompt = str(template)
|
|
134
|
+
if transcript:
|
|
135
|
+
prompt = f"{self.transcript}\n\n<task>\n{prompt}\n</task>"
|
|
136
|
+
else:
|
|
137
|
+
if transcript:
|
|
138
|
+
template = f"{{transcript}}\n\n<task>\n{template}\n</task>"
|
|
139
|
+
prompt = template.format(*args, **kwargs, transcript=self.transcript)
|
|
140
|
+
schema = (
|
|
141
|
+
json.dumps(response_model.model_json_schema() | {"additionalProperties": False})
|
|
142
|
+
if response_model
|
|
143
|
+
else None
|
|
144
|
+
)
|
|
145
|
+
backend = LlmBackends.for_specialty(specialty)
|
|
146
|
+
schema_path = self.resolve_schema_path(backend, schema)
|
|
147
|
+
|
|
148
|
+
cmd = backend.build_command(backend.models[model], schema_path, agent)
|
|
149
|
+
raw = self.call_cli(cmd, input=prompt, timeout=timeout, env=backend.env())
|
|
150
|
+
return backend.parse_response(raw, response_model)
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def resolve_schema_path(backend: LlmBackend, schema: str | None) -> str | None:
|
|
154
|
+
if not schema:
|
|
155
|
+
return None
|
|
156
|
+
if isinstance(backend, CodexBackend):
|
|
157
|
+
fd, path = tempfile.mkstemp(suffix=".json")
|
|
158
|
+
os.write(fd, schema.encode())
|
|
159
|
+
os.close(fd)
|
|
160
|
+
return path
|
|
161
|
+
return schema
|
captain_hook/dispatch.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Hook dispatch: select matching hooks, run their handlers, and translate ``HookResult`` into the Claude Code stdout envelope."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from loguru import logger
|
|
8
|
+
|
|
9
|
+
from captain_hook.app import get_matching_hooks
|
|
10
|
+
from captain_hook.session import SessionStore
|
|
11
|
+
from captain_hook.state import HookState
|
|
12
|
+
from captain_hook.types import Action, Event, HookResult, HookSpec, RegisteredHook
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from captain_hook.events import BaseHookEvent
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run_declarative(spec: HookSpec, evt: BaseHookEvent) -> HookResult | None:
|
|
19
|
+
return (
|
|
20
|
+
HookResult(action=Action.block if spec.block else Action.warn, message=spec.message) if spec.message else None
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def execute_hook(
|
|
25
|
+
entry: RegisteredHook,
|
|
26
|
+
evt: BaseHookEvent,
|
|
27
|
+
session_dir: Path | None = None,
|
|
28
|
+
) -> HookResult | None:
|
|
29
|
+
"""Execute a single registered hook, respecting ``max_fires`` and persisting fire count."""
|
|
30
|
+
hook_session_dir = (session_dir / entry.name) if session_dir else None
|
|
31
|
+
if hook_session_dir:
|
|
32
|
+
hook_session_dir.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
|
|
34
|
+
store = SessionStore(hook_session_dir)
|
|
35
|
+
hook_state = store[HookState].get(HookState())
|
|
36
|
+
|
|
37
|
+
if entry.spec.max_fires is not None and hook_state.fire_count >= entry.spec.max_fires:
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
result = entry.handler(evt) if entry.handler else run_declarative(entry.spec, evt)
|
|
42
|
+
except Exception:
|
|
43
|
+
logger.bind(hook=entry.name).exception("hook handler failed")
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
if result:
|
|
47
|
+
hook_state.fire_count += 1
|
|
48
|
+
store[HookState].set(hook_state)
|
|
49
|
+
|
|
50
|
+
return result
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def format_output(event: Event, result: HookResult) -> dict[str, Any] | None:
|
|
54
|
+
"""Render a ``HookResult`` as the JSON envelope Claude Code expects on stdout for *event*."""
|
|
55
|
+
if event in (Event.Stop | Event.SubagentStop):
|
|
56
|
+
return {"decision": "block", "reason": result.message} if result.action is not Action.allow else None
|
|
57
|
+
|
|
58
|
+
match result.action:
|
|
59
|
+
case Action.block:
|
|
60
|
+
return {
|
|
61
|
+
"hookSpecificOutput": {
|
|
62
|
+
"hookEventName": event.name,
|
|
63
|
+
"permissionDecision": "deny",
|
|
64
|
+
"permissionDecisionReason": result.message,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
case Action.warn:
|
|
68
|
+
return {
|
|
69
|
+
"hookSpecificOutput": {
|
|
70
|
+
"hookEventName": event.name,
|
|
71
|
+
"additionalContext": result.message,
|
|
72
|
+
**({"permissionDecision": "allow"} if event is Event.PreToolUse else {}),
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
case Action.allow:
|
|
76
|
+
return {
|
|
77
|
+
"hookSpecificOutput": {
|
|
78
|
+
"hookEventName": event.name,
|
|
79
|
+
"permissionDecision": "allow",
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def dispatch(
|
|
85
|
+
event: Event,
|
|
86
|
+
evt: BaseHookEvent,
|
|
87
|
+
session_dir: Path | None = None,
|
|
88
|
+
*,
|
|
89
|
+
async_: bool = False,
|
|
90
|
+
) -> dict[str, Any] | None:
|
|
91
|
+
"""Dispatch an event to all matching hooks and return the combined result."""
|
|
92
|
+
matching = [h for h in get_matching_hooks(evt) if h.spec.async_ == async_]
|
|
93
|
+
|
|
94
|
+
warns: list[str] = []
|
|
95
|
+
for entry in matching:
|
|
96
|
+
match execute_hook(entry, evt, session_dir):
|
|
97
|
+
case HookResult(action=Action.block | Action.allow) as r:
|
|
98
|
+
return format_output(event, r)
|
|
99
|
+
case HookResult(action=Action.warn, message=msg) if msg:
|
|
100
|
+
warns.append(msg)
|
|
101
|
+
case _:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
if warns:
|
|
105
|
+
return format_output(event, HookResult(action=Action.warn, message="\n\n".join(warns)))
|
|
106
|
+
|
|
107
|
+
return None
|