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
@@ -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
@@ -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
@@ -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