sequential-hooks 0.1.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 (43) hide show
  1. plugins/agy/_sequential_hooks/__init__.py +1 -0
  2. plugins/agy/_sequential_hooks/_adapter.py +566 -0
  3. plugins/agy/_sequential_hooks/_doctor.py +413 -0
  4. plugins/claude/_sequential_hooks/__init__.py +1 -0
  5. plugins/claude/_sequential_hooks/_adapter.py +560 -0
  6. plugins/claude/_sequential_hooks/_doctor.py +455 -0
  7. plugins/codex/_sequential_hooks/__init__.py +1 -0
  8. plugins/codex/_sequential_hooks/_adapter.py +466 -0
  9. plugins/codex/_sequential_hooks/_doctor.py +563 -0
  10. sequential_hooks/__init__.py +3 -0
  11. sequential_hooks/__main__.py +5 -0
  12. sequential_hooks/_arguments.py +222 -0
  13. sequential_hooks/_cleanup.py +88 -0
  14. sequential_hooks/_cli.py +440 -0
  15. sequential_hooks/_containment/__init__.py +410 -0
  16. sequential_hooks/_containment/_posix.py +236 -0
  17. sequential_hooks/_containment/_uncontained.py +239 -0
  18. sequential_hooks/_containment/_windows.py +853 -0
  19. sequential_hooks/_containment/_windows_api.py +570 -0
  20. sequential_hooks/_containment/_windows_launcher.py +189 -0
  21. sequential_hooks/_diagnostics.py +265 -0
  22. sequential_hooks/_doctor/__init__.py +342 -0
  23. sequential_hooks/_doctor/_command.py +469 -0
  24. sequential_hooks/_doctor/_common.py +448 -0
  25. sequential_hooks/_doctor/_types.py +94 -0
  26. sequential_hooks/_downstream.py +181 -0
  27. sequential_hooks/_executable.py +187 -0
  28. sequential_hooks/_executor.py +825 -0
  29. sequential_hooks/_registry.py +103 -0
  30. sequential_hooks/_runner.py +48 -0
  31. sequential_hooks/_types.py +125 -0
  32. sequential_hooks/hosts/__init__.py +130 -0
  33. sequential_hooks/hosts/_contract.py +434 -0
  34. sequential_hooks/hosts/_inspection.py +78 -0
  35. sequential_hooks/hosts/_json.py +150 -0
  36. sequential_hooks/hosts/_records.py +273 -0
  37. sequential_hooks/hosts/_skeleton.py +1326 -0
  38. sequential_hooks/py.typed +0 -0
  39. sequential_hooks-0.1.0.dist-info/METADATA +90 -0
  40. sequential_hooks-0.1.0.dist-info/RECORD +43 -0
  41. sequential_hooks-0.1.0.dist-info/WHEEL +4 -0
  42. sequential_hooks-0.1.0.dist-info/entry_points.txt +7 -0
  43. sequential_hooks-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,413 @@
1
+ """Inspect Antigravity hook configuration sources."""
2
+
3
+ from collections.abc import Iterable
4
+ from pathlib import Path
5
+ from typing import cast
6
+
7
+ from sequential_hooks.hosts import (
8
+ ConfigSource,
9
+ Finding,
10
+ FindingSeverity,
11
+ InspectedCommand,
12
+ JsonObjectExpectedError,
13
+ JsonProtocolError,
14
+ LaunchSemantics,
15
+ Registration,
16
+ SourceKind,
17
+ load_object_text,
18
+ parse_timeout,
19
+ project_layers,
20
+ )
21
+
22
+ _DEFAULT_HANDLER_TIMEOUT = 30.0
23
+ _LIMITATIONS = (
24
+ (
25
+ 'organization-managed Antigravity hook sources cannot be inspected until '
26
+ 'their exact readable source paths are established'
27
+ ),
28
+ (
29
+ 'product-internal or plugin-provided Antigravity hooks cannot be inspected until '
30
+ 'their exact readable source paths are established; plugin data is not scanned'
31
+ ),
32
+ )
33
+ _NATIVE_EVENTS = frozenset(
34
+ {'PostInvocation', 'PostToolUse', 'PreInvocation', 'PreToolUse', 'Stop'}
35
+ )
36
+ _WRAPPER_EVENTS = frozenset({'PostToolUse', 'PreToolUse'})
37
+
38
+
39
+ class AgyDoctorLoader:
40
+ """Inspect the Antigravity hooks files through the published `DoctorLoader` contract."""
41
+
42
+ def analyze_registration(
43
+ self,
44
+ registration: Registration,
45
+ inspected: InspectedCommand,
46
+ *,
47
+ windows: bool,
48
+ ) -> tuple[Finding, ...]:
49
+ """Return host-specific findings for one inspected registration.
50
+
51
+ Args:
52
+ registration: Accepted registration.
53
+ inspected: Static inspection of the registration's command.
54
+ windows: Whether Windows launch semantics apply.
55
+
56
+ Returns:
57
+ Findings in report order.
58
+ """
59
+ return _analyze_registration(registration, inspected, windows=windows)
60
+
61
+ def discover_sources(
62
+ self,
63
+ home: Path,
64
+ cwd: Path,
65
+ system_root: Path,
66
+ ) -> tuple[ConfigSource, ...]:
67
+ """Return every configuration source in deterministic inspection order.
68
+
69
+ Args:
70
+ home: Home directory used to locate user configuration.
71
+ cwd: Working directory used to locate project configuration.
72
+ system_root: Filesystem root used to locate system configuration.
73
+
74
+ Returns:
75
+ Candidate sources, existing or not, in inspection order.
76
+ """
77
+ return _discover_sources(home, cwd, system_root)
78
+
79
+ def inspection_limitations(self) -> tuple[str, ...]:
80
+ """Return configuration sources static inspection cannot establish.
81
+
82
+ Returns:
83
+ Stable limitation descriptions in report order.
84
+ """
85
+ return _inspection_limitations()
86
+
87
+ def load_registrations(
88
+ self,
89
+ sources: tuple[ConfigSource, ...],
90
+ ) -> tuple[tuple[Registration, ...], tuple[Finding, ...]]:
91
+ """Extract registrations and structural findings in source order.
92
+
93
+ Args:
94
+ sources: Sources returned by `discover_sources`.
95
+
96
+ Returns:
97
+ Accepted registrations and source-isolated findings.
98
+ """
99
+ return _load_registrations(sources)
100
+
101
+
102
+ def _analyze_registration(
103
+ registration: Registration,
104
+ inspected: InspectedCommand,
105
+ *,
106
+ windows: bool,
107
+ ) -> tuple[Finding, ...]:
108
+ """Return Antigravity findings for an inspected registration."""
109
+ if windows or registration.event not in _WRAPPER_EVENTS:
110
+ return ()
111
+ findings: list[Finding] = []
112
+ if inspected.invocation is None:
113
+ if (
114
+ inspected.argv is not None
115
+ and inspected.error is not None
116
+ and inspected.contract_error is None
117
+ ):
118
+ findings.append(
119
+ Finding(
120
+ FindingSeverity.WARNING,
121
+ 'agy_wrapper_contract_uninspectable',
122
+ 'Antigravity wrapper host and event cannot be inspected from '
123
+ 'malformed literal args',
124
+ 'Check the literal wrapper argument grammar, including '
125
+ '--agent agy and --event.',
126
+ )
127
+ )
128
+ return tuple(findings)
129
+
130
+ invocation = inspected.invocation
131
+ if invocation.step_timeout is None:
132
+ return tuple(findings)
133
+ chain_budget = len(invocation.steps) * invocation.step_timeout
134
+ if registration.timeout is None:
135
+ raise AssertionError('validated registration timeout is missing')
136
+ if chain_budget >= registration.timeout:
137
+ findings.append(
138
+ Finding(
139
+ FindingSeverity.WARNING,
140
+ 'agy_chain_budget_uncontained',
141
+ 'configured sequential-hooks chain budget leaves no room within the '
142
+ 'outer Antigravity handler timeout',
143
+ 'Set the outer Antigravity handler timeout above the configured chain budget.',
144
+ )
145
+ )
146
+ return tuple(findings)
147
+
148
+
149
+ def _discover_sources(
150
+ home: Path,
151
+ cwd: Path,
152
+ system_root: Path,
153
+ ) -> tuple[ConfigSource, ...]:
154
+ """Return documented Antigravity files in inspection order."""
155
+ del system_root
156
+ paths: list[tuple[Path, SourceKind]] = [(home / '.gemini/config/hooks.json', SourceKind.USER)]
157
+ paths.extend(
158
+ (layer / '.agents/hooks.json', SourceKind.PROJECT) for layer in project_layers(cwd)
159
+ )
160
+ return tuple(ConfigSource(path, kind, path.exists()) for path, kind in paths)
161
+
162
+
163
+ def _finding(
164
+ source: ConfigSource,
165
+ code: str,
166
+ message: str,
167
+ correction: str | None = None,
168
+ ) -> Finding:
169
+ """Build a source-specific structural finding."""
170
+ return Finding(
171
+ FindingSeverity.WARNING,
172
+ code,
173
+ f'Antigravity configuration at {source.path} {message}',
174
+ correction,
175
+ )
176
+
177
+
178
+ def _handler_validation_finding(
179
+ source: ConfigSource,
180
+ handler: object,
181
+ ) -> Finding | None:
182
+ """Return the first structural problem in one handler."""
183
+ if not isinstance(handler, dict):
184
+ return _finding(source, 'agy_hook_malformed', 'contains a malformed hook handler')
185
+ handler_data = cast('dict[str, object]', handler)
186
+ handler_type = handler_data.get('type', 'command')
187
+ if handler_type != 'command':
188
+ return _finding(
189
+ source,
190
+ 'agy_hook_type_unsupported',
191
+ 'contains a hook whose type is not the supported command form',
192
+ )
193
+ if not isinstance(handler_data.get('command'), str) or not handler_data.get('command'):
194
+ return _finding(
195
+ source,
196
+ 'agy_command_malformed',
197
+ 'contains a command hook without a non-empty string command',
198
+ )
199
+ if 'args' in handler_data and (
200
+ not isinstance(raw_args := handler_data['args'], list)
201
+ or not all(isinstance(argument, str) for argument in cast('list[object]', raw_args))
202
+ ):
203
+ return _finding(
204
+ source,
205
+ 'agy_args_malformed',
206
+ 'contains a command hook with malformed args',
207
+ )
208
+ if 'timeout' in handler_data and parse_timeout(handler_data['timeout'], positive=True) is None:
209
+ return _finding(
210
+ source,
211
+ 'agy_timeout_malformed',
212
+ 'contains a command hook with a malformed timeout',
213
+ )
214
+ return None
215
+
216
+
217
+ def _inspection_limitations() -> tuple[str, ...]:
218
+ """Return Antigravity sources static file discovery cannot inspect."""
219
+ return _LIMITATIONS
220
+
221
+
222
+ def _load_source(source: ConfigSource) -> tuple[dict[str, object] | None, Finding | None]:
223
+ """Load one strict JSON source without exposing its content."""
224
+ try:
225
+ text = source.path.read_text(encoding='utf-8')
226
+ except UnicodeDecodeError:
227
+ return None, _finding(
228
+ source,
229
+ 'agy_source_encoding_malformed',
230
+ 'contains malformed UTF-8',
231
+ )
232
+ except OSError:
233
+ return None, _finding(source, 'agy_source_unreadable', 'cannot be read')
234
+ try:
235
+ loaded = load_object_text(text)
236
+ except JsonObjectExpectedError:
237
+ return None, _finding(source, 'agy_settings_malformed', 'must contain a JSON object')
238
+ except JsonProtocolError:
239
+ return None, _finding(source, 'agy_json_malformed', 'contains malformed JSON')
240
+ return cast('dict[str, object]', loaded), None
241
+
242
+
243
+ def _registration(
244
+ source: ConfigSource,
245
+ event: str,
246
+ matcher: str | None,
247
+ handler: object,
248
+ *,
249
+ enabled: bool | None,
250
+ ) -> tuple[Registration | None, list[Finding]]:
251
+ """Parse one command handler."""
252
+ validation_finding = _handler_validation_finding(source, handler)
253
+ if validation_finding is not None:
254
+ return None, [validation_finding]
255
+ handler_data = cast('dict[str, object]', handler)
256
+ raw_args = cast('list[str] | None', handler_data.get('args'))
257
+ raw_timeout = handler_data.get('timeout')
258
+ timeout = _DEFAULT_HANDLER_TIMEOUT
259
+ if raw_timeout is not None:
260
+ parsed_timeout = parse_timeout(raw_timeout, positive=True)
261
+ if parsed_timeout is None:
262
+ raise AssertionError('validated handler timeout is missing')
263
+ timeout = parsed_timeout
264
+ findings: list[Finding] = []
265
+ if raw_timeout is None:
266
+ findings.append(
267
+ Finding(
268
+ FindingSeverity.INFO,
269
+ 'agy_timeout_default_documented',
270
+ 'As documented on 2026-07-28, Antigravity defaults command handler '
271
+ f'timeouts to {_DEFAULT_HANDLER_TIMEOUT:g} seconds',
272
+ )
273
+ )
274
+ if event not in _NATIVE_EVENTS:
275
+ findings.append(
276
+ Finding(
277
+ FindingSeverity.WARNING,
278
+ 'agy_native_event_unknown',
279
+ 'Antigravity native event is not a documented Antigravity hook event',
280
+ 'Use a documented Antigravity hook event name.',
281
+ )
282
+ )
283
+ registration = Registration(
284
+ source=source,
285
+ event=event,
286
+ matcher=matcher,
287
+ command=cast('str', handler_data['command']),
288
+ args=tuple(raw_args) if isinstance(raw_args, list) else None,
289
+ launch_semantics=LaunchSemantics.POSIX_SHELL,
290
+ command_windows=None,
291
+ windows_launch_semantics=LaunchSemantics.WINDOWS_SHELL,
292
+ timeout=timeout,
293
+ enabled=enabled,
294
+ type=cast('str', handler_data.get('type', 'command')),
295
+ configuration_findings=tuple(findings),
296
+ )
297
+ return registration, []
298
+
299
+
300
+ def _event_registrations(
301
+ source: ConfigSource,
302
+ event: object,
303
+ groups: object,
304
+ *,
305
+ enabled: bool | None,
306
+ ) -> tuple[list[Registration], list[Finding]]:
307
+ """Parse one event layer."""
308
+ if not isinstance(event, str) or not isinstance(groups, list):
309
+ return [], [_finding(source, 'agy_event_malformed', 'contains a malformed event')]
310
+ registrations: list[Registration] = []
311
+ findings: list[Finding] = []
312
+ for group in cast('list[object]', groups):
313
+ if not isinstance(group, dict):
314
+ findings.append(
315
+ _finding(
316
+ source,
317
+ 'agy_matcher_group_malformed',
318
+ 'contains a malformed matcher group',
319
+ )
320
+ )
321
+ continue
322
+ group_data = cast('dict[str, object]', group)
323
+ matcher = group_data.get('matcher')
324
+ if 'matcher' in group_data and not isinstance(matcher, str):
325
+ findings.append(
326
+ _finding(source, 'agy_matcher_malformed', 'contains a malformed matcher')
327
+ )
328
+ continue
329
+ handlers = group_data.get('hooks')
330
+ if not isinstance(handlers, list):
331
+ findings.append(
332
+ _finding(
333
+ source,
334
+ 'agy_handler_list_malformed',
335
+ 'contains a malformed hook handler list',
336
+ )
337
+ )
338
+ continue
339
+ for handler in cast('list[object]', handlers):
340
+ registration, handler_findings = _registration(
341
+ source,
342
+ event,
343
+ matcher if isinstance(matcher, str) else None,
344
+ handler,
345
+ enabled=enabled,
346
+ )
347
+ if registration is not None:
348
+ registrations.append(registration)
349
+ findings.extend(handler_findings)
350
+ return registrations, findings
351
+
352
+
353
+ def _named_registrations(
354
+ source: ConfigSource,
355
+ loaded: dict[str, object],
356
+ ) -> tuple[list[Registration], list[Finding]]:
357
+ """Parse the documented named-hook map."""
358
+ registrations: list[Registration] = []
359
+ findings: list[Finding] = []
360
+ for named_hook in loaded.values():
361
+ if not isinstance(named_hook, dict):
362
+ findings.append(
363
+ _finding(
364
+ source,
365
+ 'agy_named_hook_malformed',
366
+ 'contains a malformed named hook',
367
+ )
368
+ )
369
+ continue
370
+ hook_data = cast('dict[str, object]', named_hook)
371
+ enabled = hook_data.get('enabled', True)
372
+ if not isinstance(enabled, bool):
373
+ findings.append(
374
+ _finding(
375
+ source,
376
+ 'agy_enabled_malformed',
377
+ 'contains a named hook with malformed enabled state',
378
+ )
379
+ )
380
+ continue
381
+ for event, groups in hook_data.items():
382
+ if event == 'enabled':
383
+ continue
384
+ event_registrations, event_findings = _event_registrations(
385
+ source,
386
+ event,
387
+ groups,
388
+ enabled=enabled,
389
+ )
390
+ registrations.extend(event_registrations)
391
+ findings.extend(event_findings)
392
+ return registrations, findings
393
+
394
+
395
+ def _load_registrations(
396
+ sources: Iterable[ConfigSource],
397
+ ) -> tuple[tuple[Registration, ...], tuple[Finding, ...]]:
398
+ """Extract registrations and findings in source order."""
399
+ registrations: list[Registration] = []
400
+ findings: list[Finding] = []
401
+ for source in sources:
402
+ if not source.exists:
403
+ continue
404
+ loaded, finding = _load_source(source)
405
+ if finding is not None:
406
+ findings.append(finding)
407
+ continue
408
+ if loaded is None:
409
+ continue
410
+ source_registrations, source_findings = _named_registrations(source, loaded)
411
+ registrations.extend(source_registrations)
412
+ findings.extend(source_findings)
413
+ return tuple(registrations), tuple(findings)
@@ -0,0 +1 @@
1
+ """Provide the private Claude Code adapter implementation."""