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.
Files changed (105) hide show
  1. {capt_hook-4.5.0 → capt_hook-5.1.0}/PKG-INFO +1 -1
  2. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/__init__.py +7 -2
  3. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/app.py +44 -77
  4. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/classifiers/__init__.py +1 -3
  5. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/classifiers/conductor.py +8 -8
  6. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/cli.py +20 -4
  7. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/command.py +27 -30
  8. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/conditions.py +78 -18
  9. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/loader.py +9 -7
  10. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/general/capt-hook.toml +1 -1
  11. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/general/commands.py +12 -9
  12. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/general/docs.py +1 -1
  13. capt_hook-5.1.0/captain_hook/packs/general/models.py +133 -0
  14. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/general/prompts.py +15 -13
  15. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/general/tasks.py +1 -3
  16. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/go/testing.py +3 -3
  17. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/manager.py +15 -21
  18. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/python/testing.py +3 -3
  19. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/steering/steering.py +21 -1
  20. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/primitives/commands.py +18 -6
  21. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/primitives/lint.py +8 -5
  22. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/primitives/llm.py +9 -8
  23. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/primitives/nudge.py +49 -9
  24. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/primitives/rewrite.py +8 -1
  25. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/primitives/workflow.py +12 -4
  26. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/review/dashboard.py +1 -3
  27. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/review/fix.py +9 -14
  28. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/review/formats.py +4 -2
  29. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/review/judge.py +74 -86
  30. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/review/pipeline.py +9 -8
  31. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/review/scan.py +4 -7
  32. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/review/store.py +126 -130
  33. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/settings.py +8 -8
  34. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +22 -21
  35. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/style/__init__.py +2 -5
  36. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/style/matchers.py +5 -8
  37. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/testing/helpers.py +65 -19
  38. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/testing/types.py +26 -6
  39. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/types.py +189 -38
  40. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/util/http.py +1 -3
  41. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/util/model_cache.py +11 -8
  42. {capt_hook-4.5.0 → capt_hook-5.1.0}/pyproject.toml +1 -1
  43. capt_hook-4.5.0/captain_hook/packs/general/models.py +0 -67
  44. {capt_hook-4.5.0 → capt_hook-5.1.0}/LICENSE +0 -0
  45. {capt_hook-4.5.0 → capt_hook-5.1.0}/README.md +0 -0
  46. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/.claude-plugin/plugin.json +0 -0
  47. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/__main__.py +0 -0
  48. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/ast_grep.py +0 -0
  49. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/classifiers/droid.py +0 -0
  50. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/classifiers/native.py +0 -0
  51. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/context.py +0 -0
  52. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/decisions.py +0 -0
  53. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/dispatch.py +0 -0
  54. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/durable.py +0 -0
  55. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/events.py +0 -0
  56. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/fields.py +0 -0
  57. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/file.py +0 -0
  58. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/log.py +0 -0
  59. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/__init__.py +0 -0
  60. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/general/__init__.py +0 -0
  61. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/general/plans.py +0 -0
  62. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/general/review.py +0 -0
  63. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/go/__init__.py +0 -0
  64. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/go/capt-hook.toml +0 -0
  65. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/go/toolchain.py +0 -0
  66. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/python/__init__.py +0 -0
  67. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/python/capt-hook.toml +0 -0
  68. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/python/style.py +0 -0
  69. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/python/toolchain.py +0 -0
  70. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/steering/__init__.py +0 -0
  71. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/packs/steering/capt-hook.toml +0 -0
  72. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/primitives/__init__.py +0 -0
  73. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/prompt.py +0 -0
  74. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/py.typed +0 -0
  75. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/review/__init__.py +0 -0
  76. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/review/cli.py +0 -0
  77. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/review/repo.py +0 -0
  78. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/review/settings.py +0 -0
  79. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/review/sync.py +0 -0
  80. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/session.py +0 -0
  81. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/signals/__init__.py +0 -0
  82. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/signals/nlp.py +0 -0
  83. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/skills/authoring-hooks/SKILL.md +0 -0
  84. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +0 -0
  85. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
  86. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +0 -0
  87. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +0 -0
  88. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/skills/scanning-sessions/SKILL.md +0 -0
  89. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
  90. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
  91. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
  92. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
  93. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
  94. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
  95. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/state.py +0 -0
  96. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/style/ast_grep.py +0 -0
  97. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/style/scope.py +0 -0
  98. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/style/types.py +0 -0
  99. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/tasks.py +0 -0
  100. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
  101. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/testing/__init__.py +0 -0
  102. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/testing/session_cache.py +0 -0
  103. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/util/__init__.py +0 -0
  104. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/util/fs.py +0 -0
  105. {capt_hook-4.5.0 → capt_hook-5.1.0}/captain_hook/util/text.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: capt-hook
3
- Version: 4.5.0
3
+ Version: 5.1.0
4
4
  Summary: Declarative hook framework for Claude Code
5
5
  Keywords: claude,claude-code,hooks,llm,agents,guardrails,cli
6
6
  Author: Yasyf Mohamedali
@@ -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, register
24
- from captain_hook.command import Command, CommandLine, Redirect
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
- def validate_conditions(conditions: Sequence[TCondition], label: str) -> None:
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
- if message is None:
119
- raise TypeError(
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 CLASSIFIER_MODULES
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(SYNTHETIC_PREFIXES)
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", {}) | MARKETPLACE,
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
- print(f"\n{total} tests: {passed} passed, {failed} failed, {errors} errors, {skipped} skipped")
452
- if failed or errors:
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
- BASH_LANGUAGE = Language(tsbash.language()) # pyright: ignore[reportDeprecated]
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 Command:
28
+ class ParsedCommand:
32
29
  """A single parsed shell command with executable, arguments, env vars, and redirects.
33
30
 
34
- Use ``Command.parse(raw)`` to parse a command string, or access via ``CommandLine``.
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) -> Command:
41
+ def parse(cls, raw: str) -> ParsedCommand:
45
42
  return CommandLine.parse(raw).primary
46
43
 
47
44
  @classmethod
48
- def empty(cls) -> Command:
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[Command, str | None], ...]
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[Command, ...]:
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) -> Command:
108
- return self.parts[-1][0] if self.parts else Command.empty()
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) -> Command:
112
- return self.parts[0][0] if self.parts else Command.empty()
108
+ def head(self) -> ParsedCommand:
109
+ return self.parts[0][0] if self.parts else ParsedCommand.empty()
113
110
 
114
- def __iter__(self) -> Iterator[Command]:
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) -> Command:
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 Command(
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[Command, str | None]]:
248
- parts: list[tuple[Command, str | None]] = []
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[Command, str | None]]:
258
+ def walk_redirected(node: Node) -> list[tuple[ParsedCommand, str | None]]:
262
259
  redirects: list[Redirect] = []
263
- inner_parts: list[tuple[Command, str | None]] = []
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
- Command(
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
- Command(
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[Command, str | None]]:
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, COMPOUND_OPS)
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[Command, str | None]] = []
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) -> Command:
316
- return Command(raw=raw, executable=raw.split()[0] if raw.split() else raw, args=())
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[[Command], bool]) -> bool:
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 ``Command``.
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 bool(re.search(cond.pattern, source, re.MULTILINE))
107
- return cond.model is not None and any(
108
- re.search(cond.model, pinned) for pinned in re.findall(r"""model\s*:\s*['"]([^'"]*)['"]""", source)
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(pattern):
125
- return bool(evt.tool_name) and tool_name_matches(evt.tool_name, pattern)
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((cl := evt.command_line) and any(re.search(pattern, str(cmd)) for cmd in cl.commands))
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(field, pattern):
137
- return bool(
138
- evt.tool_name
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(name):
153
- return bool(evt.agent_type) and evt.agent_type in name.split("|")
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 is None or f.matches(paths))
217
+ and (not paths or f.matches(*paths))
164
218
  )
165
- case UsedSkill(name, subagents):
166
- return evt.ctx.transcript.has_skill(*name.split("|"), subagents=subagents)
219
+ case UsedSkill(names, subagents):
220
+ return has_used_skill(evt.ctx.transcript, names, subagents=subagents)
167
221
  case ReadFile(patterns, subagents):
168
- return any(evt.ctx.transcript.has_read(p, subagents=subagents) for p in patterns)
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():