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 @@
1
+ """Provide the private Antigravity adapter implementation."""
@@ -0,0 +1,566 @@
1
+ """Validate and compose Antigravity hook events."""
2
+
3
+ # pyright: reportPrivateUsage=false
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+ from importlib import import_module
9
+ from typing import TYPE_CHECKING, cast
10
+
11
+ from sequential_hooks.hosts import (
12
+ API_VERSION,
13
+ TERMINATION_FAILURES,
14
+ UNCONTAINED_WARNING,
15
+ AdapterOutputError,
16
+ HostInputError,
17
+ JsonProtocolError,
18
+ NativeResponse,
19
+ OutputFailureKind,
20
+ continue_after_failure,
21
+ encode_response,
22
+ labeled,
23
+ load_object,
24
+ )
25
+
26
+ if TYPE_CHECKING:
27
+ from collections.abc import Mapping
28
+
29
+ from plugins.agy._sequential_hooks._doctor import AgyDoctorLoader
30
+ from sequential_hooks.hosts import (
31
+ ChainControl,
32
+ Diagnostics,
33
+ DoctorLoader,
34
+ FinalResponse,
35
+ JsonObject,
36
+ JsonValue,
37
+ Step,
38
+ StepResult,
39
+ )
40
+
41
+ _AGY_PRE_RANK = {
42
+ 'neutral': 0,
43
+ 'allow': 1,
44
+ 'ask': 2,
45
+ 'force_ask': 3,
46
+ 'deny': 4,
47
+ }
48
+ _EVENTS = ('PreToolUse', 'PostToolUse')
49
+ _MINIMUM_CONFLICTING_OVERRIDE_COUNT = 2
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class _Contribution:
54
+ """Carry one fully validated Antigravity contribution."""
55
+
56
+ _decision: str
57
+ _permission_overrides: list[str] | None
58
+ _reason: str | None
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class _RankedContribution:
63
+ """Associate one accepted contribution with its child step."""
64
+
65
+ _contribution: _Contribution
66
+ _step: Step
67
+
68
+
69
+ class AgyAdapter:
70
+ """Compose child results for one validated Antigravity event.
71
+
72
+ Child results are consumed in step order and composed into an event-
73
+ specific final response. The original strict JSON input remains the input
74
+ for every child.
75
+
76
+ Attributes:
77
+ event: Validated pre-tool or post-tool event.
78
+ should_run_steps: Whether the invocation should execute child steps.
79
+ """
80
+
81
+ def __init__(
82
+ self,
83
+ event: str,
84
+ raw_input: bytes,
85
+ payload: JsonObject,
86
+ diagnostics: Diagnostics,
87
+ *,
88
+ allow_on_failure: bool,
89
+ ) -> None:
90
+ """Validate native input and initialize one Antigravity event adapter.
91
+
92
+ Args:
93
+ event: Exact CLI event selected by the caller.
94
+ raw_input: Original strict JSON bytes received from Antigravity.
95
+ payload: Decoded strict JSON object matching `raw_input`.
96
+ diagnostics: Developer-tier diagnostic sink.
97
+ allow_on_failure: Whether approved pre-tool failures may continue.
98
+
99
+ Raises:
100
+ HostInputError: If required Antigravity input is missing, has the
101
+ wrong type, or is invalid.
102
+ """
103
+ if event not in _EVENTS:
104
+ raise HostInputError('Antigravity input has an unsupported event')
105
+ step_index = payload.get('stepIdx')
106
+ if isinstance(step_index, bool) or not isinstance(step_index, int):
107
+ raise HostInputError('Antigravity input requires an integer stepIdx')
108
+ if event == 'PreToolUse':
109
+ tool_call = payload.get('toolCall')
110
+ if not isinstance(tool_call, dict):
111
+ raise HostInputError('Antigravity PreToolUse input requires object toolCall')
112
+ tool_name = tool_call.get('name')
113
+ if not isinstance(tool_name, str):
114
+ raise HostInputError('Antigravity PreToolUse input requires string toolCall.name')
115
+ if not tool_name:
116
+ raise HostInputError(
117
+ 'Antigravity PreToolUse input requires non-empty toolCall.name'
118
+ )
119
+ if not isinstance(tool_call.get('args'), dict):
120
+ raise HostInputError('Antigravity PreToolUse input requires object toolCall.args')
121
+ self._should_run_steps = not (
122
+ event == 'PostToolUse' and 'toolCall' in payload and payload['toolCall'] is None
123
+ )
124
+ self._allow_on_failure = allow_on_failure
125
+ self._diagnostics = diagnostics
126
+ self._event = event
127
+ self._raw_input = raw_input
128
+ self._response_bytes: bytes | None = None
129
+ self._uncontained_warning_stored = False
130
+ self._warnings: list[str] = []
131
+ self._winning: list[_RankedContribution] = []
132
+ self._winning_rank = 0
133
+
134
+ def _classify_pre(self, value: JsonObject) -> _Contribution:
135
+ """Validate one complete Antigravity pre-tool object."""
136
+ allowed_fields = {'decision', 'permissionOverrides', 'reason'}
137
+ unknown_fields = set(value) - allowed_fields
138
+ if unknown_fields:
139
+ field_count = len(unknown_fields)
140
+ field_label = 'field' if field_count == 1 else 'fields'
141
+ raise AdapterOutputError(
142
+ OutputFailureKind.UNSUPPORTED,
143
+ f'Antigravity output has {field_count} unsupported {field_label}',
144
+ )
145
+ decision = value.get('decision')
146
+ if not isinstance(decision, str) or decision not in _AGY_PRE_RANK:
147
+ raise AdapterOutputError(
148
+ OutputFailureKind.MALFORMED,
149
+ 'Antigravity pre-tool output requires a supported decision',
150
+ )
151
+ reason_value = value.get('reason')
152
+ if 'reason' in value and not isinstance(reason_value, str):
153
+ raise AdapterOutputError(
154
+ OutputFailureKind.MALFORMED,
155
+ 'Antigravity reason must be a string',
156
+ )
157
+ reason = reason_value if isinstance(reason_value, str) else None
158
+ overrides_value = value.get('permissionOverrides')
159
+ overrides: list[str] | None = None
160
+ if 'permissionOverrides' in value:
161
+ if not isinstance(overrides_value, list) or not all(
162
+ isinstance(item, str) for item in overrides_value
163
+ ):
164
+ raise AdapterOutputError(
165
+ OutputFailureKind.MALFORMED,
166
+ 'Antigravity permissionOverrides must be an array of strings',
167
+ )
168
+ overrides = cast('list[str]', overrides_value)
169
+ return _Contribution(decision, overrides, reason)
170
+
171
+ def _conflicting_override_steps(
172
+ self,
173
+ contributions: list[_RankedContribution],
174
+ ) -> list[Step]:
175
+ """Return winning steps when their non-empty override arrays conflict."""
176
+ with_overrides = [
177
+ item for item in contributions if item._contribution._permission_overrides
178
+ ]
179
+ if len(with_overrides) < _MINIMUM_CONFLICTING_OVERRIDE_COUNT:
180
+ return []
181
+ first = with_overrides[0]._contribution._permission_overrides
182
+ if all(item._contribution._permission_overrides == first for item in with_overrides):
183
+ return []
184
+ return [item._step for item in with_overrides]
185
+
186
+ def _winning_overrides(
187
+ self,
188
+ contributions: list[_RankedContribution],
189
+ ) -> list[str] | None:
190
+ """Return the first non-empty override array at the winning rank."""
191
+ return next(
192
+ (
193
+ item._contribution._permission_overrides
194
+ for item in contributions
195
+ if item._contribution._permission_overrides
196
+ ),
197
+ None,
198
+ )
199
+
200
+ def _pre_object(
201
+ self,
202
+ contributions: list[_RankedContribution],
203
+ warnings: list[str],
204
+ *,
205
+ shed_reasons: bool = False,
206
+ ) -> JsonObject:
207
+ """Build one native pre-tool response from immutable candidate data."""
208
+ winning_decision = contributions[0]._contribution._decision if contributions else 'allow'
209
+ decision = 'allow' if winning_decision == 'neutral' else winning_decision
210
+ response: JsonObject = {'decision': decision}
211
+ overrides = None if decision == 'deny' else self._winning_overrides(contributions)
212
+ reasons = [
213
+ labeled(item._step, item._contribution._reason)
214
+ for item in contributions
215
+ if item._contribution._reason
216
+ ]
217
+ if reasons and decision in ('ask', 'force_ask', 'deny'):
218
+ reasons.extend(warnings)
219
+ if reasons and not shed_reasons:
220
+ response['reason'] = '\n\n'.join(reasons)
221
+ if overrides is not None:
222
+ response['permissionOverrides'] = cast('list[JsonValue]', overrides)
223
+ return response
224
+
225
+ def _encode_pre_candidate(
226
+ self,
227
+ contributions: list[_RankedContribution],
228
+ warnings: list[str],
229
+ *,
230
+ shed_reasons: bool = False,
231
+ ) -> bytes:
232
+ """Build and encode one native pre-tool response candidate."""
233
+ return encode_response(
234
+ self._pre_object(
235
+ contributions,
236
+ warnings,
237
+ shed_reasons=shed_reasons,
238
+ )
239
+ )
240
+
241
+ def _fit_pre_candidate(
242
+ self,
243
+ contributions: list[_RankedContribution],
244
+ warnings: list[str],
245
+ ) -> bytes:
246
+ """Build and encode the bounded native pre-tool response candidate."""
247
+ try:
248
+ return self._encode_pre_candidate(contributions, warnings)
249
+ except AdapterOutputError as error:
250
+ if error.kind is not OutputFailureKind.OVERSIZED:
251
+ raise
252
+ return self._encode_pre_candidate(
253
+ contributions,
254
+ warnings,
255
+ shed_reasons=True,
256
+ )
257
+
258
+ def _apply_failure(
259
+ self,
260
+ step: Step,
261
+ failure: str,
262
+ message: str,
263
+ *,
264
+ uncontained: bool = False,
265
+ ) -> ChainControl:
266
+ """Apply the common policy to one classified child failure."""
267
+ candidate_warnings = [*self._warnings]
268
+ warning_stored = self._uncontained_warning_stored
269
+ if uncontained and failure in TERMINATION_FAILURES:
270
+ self._diagnostics.write_step(step, UNCONTAINED_WARNING)
271
+ if not warning_stored:
272
+ candidate_warnings.append(labeled(step, UNCONTAINED_WARNING))
273
+ warning_stored = True
274
+ category = failure.replace('_', ' ')
275
+ detail = message or f'{category} child result'
276
+ self._diagnostics.write_step(step, f'{category}: {detail}')
277
+ warning = labeled(step, f'{category}: {detail}')
278
+ should_continue = continue_after_failure(
279
+ self._event == 'PreToolUse',
280
+ failure,
281
+ allow_on_failure=self._allow_on_failure,
282
+ )
283
+ if should_continue:
284
+ candidate_warnings.append(warning)
285
+ candidate_winning = self._winning
286
+ candidate_rank = self._winning_rank
287
+ else:
288
+ candidate_winning = [
289
+ _RankedContribution(
290
+ _Contribution('deny', None, f'{category}: {detail}'),
291
+ step,
292
+ )
293
+ ]
294
+ candidate_rank = _AGY_PRE_RANK['deny']
295
+ try:
296
+ if self._event == 'PostToolUse':
297
+ response_bytes = encode_response({})
298
+ else:
299
+ response_bytes = self._fit_pre_candidate(
300
+ candidate_winning,
301
+ candidate_warnings,
302
+ )
303
+ except AdapterOutputError as error:
304
+ if failure in OutputFailureKind:
305
+ raise
306
+ return self._apply_failure(step, error.kind.value, str(error))
307
+ self._uncontained_warning_stored = warning_stored
308
+ self._warnings = candidate_warnings
309
+ self._winning = candidate_winning
310
+ self._winning_rank = candidate_rank
311
+ if should_continue:
312
+ return 'continue'
313
+ self._response_bytes = response_bytes
314
+ return 'stop'
315
+
316
+ def _apply_pre(self, step: Step, contribution: _Contribution) -> ChainControl:
317
+ """Apply one fully validated pre-tool contribution."""
318
+ new_rank = _AGY_PRE_RANK[contribution._decision]
319
+ current_rank = self._winning_rank
320
+ terminal = contribution._decision == 'deny'
321
+ if new_rank >= current_rank:
322
+ prior = [] if new_rank > current_rank else self._winning
323
+ candidate = [*prior, _RankedContribution(contribution, step)]
324
+ conflict_steps = self._conflicting_override_steps(candidate)
325
+ if conflict_steps:
326
+ indexes = ', '.join(f'step {item.index}' for item in conflict_steps)
327
+ reason = f'conflicting permissionOverrides from contributing steps: {indexes}'
328
+ new_rank = _AGY_PRE_RANK['deny']
329
+ candidate = [
330
+ _RankedContribution(
331
+ _Contribution('deny', None, reason),
332
+ step,
333
+ )
334
+ ]
335
+ terminal = True
336
+ try:
337
+ response_bytes = self._encode_pre_candidate(
338
+ candidate,
339
+ self._warnings,
340
+ )
341
+ except AdapterOutputError as error:
342
+ if error.kind is not OutputFailureKind.OVERSIZED:
343
+ return self._apply_failure(step, error.kind.value, str(error))
344
+ try:
345
+ response_bytes = self._encode_pre_candidate(
346
+ candidate,
347
+ self._warnings,
348
+ shed_reasons=True,
349
+ )
350
+ except AdapterOutputError:
351
+ failure = (
352
+ OutputFailureKind.UNDELIVERABLE_MUTATION
353
+ if self._winning_overrides(candidate) is not None
354
+ else OutputFailureKind.OVERSIZED
355
+ )
356
+ return self._apply_failure(
357
+ step,
358
+ failure.value,
359
+ 'accepted Antigravity contribution cannot fit in the final response',
360
+ )
361
+ self._winning_rank = new_rank
362
+ self._winning = candidate
363
+ if terminal:
364
+ self._response_bytes = response_bytes
365
+ return 'stop'
366
+ return 'continue'
367
+
368
+ def consume(self, step: Step, result: StepResult) -> ChainControl:
369
+ """Apply one child result and choose whether the runner continues.
370
+
371
+ Args:
372
+ step: Child that produced `result`.
373
+ result: Bounded process outcome to interpret.
374
+
375
+ Returns:
376
+ `'continue'` or `'stop'`.
377
+ """
378
+ if result.failure is not None:
379
+ control = self._apply_failure(
380
+ step,
381
+ str(result.failure.kind),
382
+ result.failure.message,
383
+ uncontained=str(result.containment) == 'uncontained',
384
+ )
385
+ elif result.returncode not in (None, 0):
386
+ control = self._apply_failure(
387
+ step,
388
+ OutputFailureKind.MALFORMED.value,
389
+ f'child exited with status {result.returncode}',
390
+ )
391
+ elif not result.stdout.lstrip(b' \t\r\n'):
392
+ control = 'continue'
393
+ else:
394
+ try:
395
+ value = load_object(result.stdout)
396
+ except JsonProtocolError as error:
397
+ control = self._apply_failure(
398
+ step,
399
+ OutputFailureKind.MALFORMED.value,
400
+ str(error),
401
+ )
402
+ else:
403
+ if self._event == 'PostToolUse':
404
+ control = (
405
+ self._apply_failure(
406
+ step,
407
+ OutputFailureKind.UNSUPPORTED.value,
408
+ 'Antigravity post-tool output must be an empty object',
409
+ )
410
+ if value
411
+ else 'continue'
412
+ )
413
+ else:
414
+ try:
415
+ contribution = self._classify_pre(value)
416
+ except AdapterOutputError as error:
417
+ control = self._apply_failure(step, error.kind.value, str(error))
418
+ else:
419
+ control = self._apply_pre(step, contribution)
420
+ return control
421
+
422
+ @property
423
+ def event(self) -> str:
424
+ """Return the validated native event.
425
+
426
+ Returns:
427
+ Current pre-tool or post-tool event.
428
+ """
429
+ return self._event
430
+
431
+ def finalize(self) -> NativeResponse:
432
+ """Return the final native Antigravity response.
433
+
434
+ Returns:
435
+ The terminal response retained by a stop, otherwise exactly one
436
+ host response with one trailing line feed.
437
+ """
438
+ if self._response_bytes is not None:
439
+ return NativeResponse(self._response_bytes)
440
+ if self._event == 'PostToolUse':
441
+ response_bytes = encode_response({})
442
+ else:
443
+ response_bytes = self._fit_pre_candidate(
444
+ self._winning,
445
+ self._warnings,
446
+ )
447
+ return NativeResponse(response_bytes)
448
+
449
+ @property
450
+ def should_run_steps(self) -> bool:
451
+ """Return whether this invocation should execute child steps.
452
+
453
+ Returns:
454
+ `False` only for an explicit-null Antigravity post-tool callback.
455
+ """
456
+ return self._should_run_steps
457
+
458
+ def step_input(self) -> bytes:
459
+ """Return the unchanged native JSON input for the next child.
460
+
461
+ Returns:
462
+ Original strict JSON input bytes.
463
+ """
464
+ return self._raw_input
465
+
466
+
467
+ class AgyHost:
468
+ """Describe the Antigravity host for entry-point discovery.
469
+
470
+ Attributes:
471
+ api_version: Host contract version implemented here.
472
+ events: Native events the wrapper serves.
473
+ name: Entry-point name selected by `--agent`.
474
+ requires_event: `True`; Antigravity payloads carry no event name.
475
+ """
476
+
477
+ api_version = API_VERSION
478
+ events = _EVENTS
479
+ name = 'agy'
480
+ requires_event = True
481
+
482
+ def adapter(
483
+ self,
484
+ event: str,
485
+ raw_input: bytes,
486
+ payload: JsonObject,
487
+ diagnostics: Diagnostics,
488
+ *,
489
+ allow_on_failure: bool,
490
+ ) -> AgyAdapter:
491
+ """Validate native input and construct the adapter for one invocation.
492
+
493
+ Args:
494
+ event: Trusted event from `--event`.
495
+ raw_input: Original native strict JSON hook input.
496
+ payload: Parsed native strict JSON object.
497
+ diagnostics: Bounded developer diagnostic sink.
498
+ allow_on_failure: Whether approved pre-tool failures may continue.
499
+
500
+ Returns:
501
+ Adapter ready to drive the chain.
502
+
503
+ Raises:
504
+ HostInputError: If the payload is not valid input for `event`.
505
+ """
506
+ return AgyAdapter(
507
+ event,
508
+ raw_input,
509
+ payload,
510
+ diagnostics,
511
+ allow_on_failure=allow_on_failure,
512
+ )
513
+
514
+ def configuration_response(
515
+ self,
516
+ event: str,
517
+ reason: str,
518
+ diagnostics: Diagnostics,
519
+ ) -> FinalResponse:
520
+ """Return the top-level deny or the empty post-tool object.
521
+
522
+ Args:
523
+ event: Trusted event, one of `events`.
524
+ reason: Wrapper diagnostic, published after response resolution.
525
+ diagnostics: Bounded developer diagnostic sink.
526
+
527
+ Returns:
528
+ PreToolUse deny JSON, or `{}` for PostToolUse, which does not
529
+ block.
530
+
531
+ Raises:
532
+ ValueError: If `event` is not one of `events`.
533
+ """
534
+ del reason
535
+ if event not in self.events:
536
+ raise ValueError(f'Antigravity has no configuration response for {event!r}')
537
+ message = '[step 1: configuration]\nspawn: sequential-hooks could not safely run this hook'
538
+ diagnostics.write_global(
539
+ 'configuration: spawn: sequential-hooks could not safely run this hook'
540
+ )
541
+ value: JsonObject = (
542
+ {'decision': 'deny', 'reason': message} if event == 'PreToolUse' else {}
543
+ )
544
+ return NativeResponse(encode_response(value))
545
+
546
+ def doctor_loader(self) -> DoctorLoader:
547
+ """Return the static Antigravity configuration loader.
548
+
549
+ Returns:
550
+ Loader over the documented Antigravity hooks files.
551
+ """
552
+ module = import_module('plugins.agy._sequential_hooks._doctor')
553
+ loader_type: type[AgyDoctorLoader] = module.AgyDoctorLoader
554
+ return loader_type()
555
+
556
+ def payload_event(self, payload: Mapping[str, object]) -> str | None:
557
+ """Return `None`; Antigravity payloads never carry the event.
558
+
559
+ Args:
560
+ payload: Parsed native strict JSON object.
561
+
562
+ Returns:
563
+ Always `None`.
564
+ """
565
+ del payload
566
+ return None