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,434 @@
1
+ """Define the structural host contract shared by every host adapter package."""
2
+
3
+ from collections.abc import Mapping, Sequence
4
+ from pathlib import Path
5
+ from typing import Literal, Protocol
6
+
7
+ from sequential_hooks.hosts._records import (
8
+ ConfigSource,
9
+ Finding,
10
+ InspectedCommand,
11
+ Registration,
12
+ )
13
+
14
+ API_VERSION = 1
15
+ CONTAINMENT_STATES = frozenset({'contained', 'uncontained'})
16
+ FAILURE_KINDS = frozenset(
17
+ {
18
+ 'not_found',
19
+ 'spawn',
20
+ 'crash',
21
+ 'timeout',
22
+ 'output_limit',
23
+ 'output_worker_timeout',
24
+ 'containment',
25
+ 'unsupported_executable',
26
+ }
27
+ )
28
+ type ChainControl = Literal['continue', 'stop']
29
+ type JsonValue = bool | int | float | str | list['JsonValue'] | dict[str, 'JsonValue'] | None
30
+ type JsonObject = dict[str, JsonValue]
31
+
32
+
33
+ class HostInputError(ValueError):
34
+ """Report an expected native-input rejection whose message is safe to surface.
35
+
36
+ A host raises it from `HostSpec.adapter` when the payload is not valid
37
+ native input for the event. Any other exception from that call is an
38
+ adapter malfunction and reaches the wrapper's last resort.
39
+ """
40
+
41
+
42
+ class Step(Protocol):
43
+ """Describe one child command in its configured chain position.
44
+
45
+ The runner constructs steps; adapters only read them.
46
+ """
47
+
48
+ @property
49
+ def argv(self) -> tuple[str, ...]:
50
+ """Return the literal executable token and arguments.
51
+
52
+ Returns:
53
+ Argument vector exactly as configured.
54
+ """
55
+ ...
56
+
57
+ @property
58
+ def index(self) -> int:
59
+ """Return the one-based chain position used in diagnostics.
60
+
61
+ Returns:
62
+ Position starting at 1.
63
+ """
64
+ ...
65
+
66
+ @property
67
+ def name(self) -> str:
68
+ """Return the executable basename used in diagnostics.
69
+
70
+ Returns:
71
+ Basename, or the original token when it has no basename.
72
+ """
73
+ ...
74
+
75
+
76
+ class StepFailure(Protocol):
77
+ """Describe one bounded infrastructure failure.
78
+
79
+ `kind` is one of `FAILURE_KINDS`; adapters compare it as a string.
80
+ """
81
+
82
+ @property
83
+ def kind(self) -> str:
84
+ """Return the stable failure category.
85
+
86
+ Returns:
87
+ One of the documented `FAILURE_KINDS` values.
88
+ """
89
+ ...
90
+
91
+ @property
92
+ def message(self) -> str:
93
+ """Return the safe user-facing failure detail.
94
+
95
+ Returns:
96
+ Bounded detail text.
97
+ """
98
+ ...
99
+
100
+ @property
101
+ def returncode(self) -> int | None:
102
+ """Return the child status when one was observed.
103
+
104
+ Returns:
105
+ Child status, or `None`.
106
+ """
107
+ ...
108
+
109
+
110
+ class StepResult(Protocol):
111
+ """Carry one child's bounded process outcome to an adapter.
112
+
113
+ `containment` is one of `CONTAINMENT_STATES`; adapters compare it as a
114
+ string.
115
+ """
116
+
117
+ @property
118
+ def blocking_stderr(self) -> str:
119
+ """Return bounded decoded stderr retained for native responses.
120
+
121
+ Returns:
122
+ Replacement-decoded child stderr text.
123
+ """
124
+ ...
125
+
126
+ @property
127
+ def containment(self) -> str:
128
+ """Return the effective process-tree containment state.
129
+
130
+ Returns:
131
+ One of the documented `CONTAINMENT_STATES` values.
132
+ """
133
+ ...
134
+
135
+ @property
136
+ def failure(self) -> StepFailure | None:
137
+ """Return the infrastructure failure, if one occurred.
138
+
139
+ Returns:
140
+ Failure record, or `None` when the child completed.
141
+ """
142
+ ...
143
+
144
+ @property
145
+ def returncode(self) -> int | None:
146
+ """Return the child status when one was observed.
147
+
148
+ Returns:
149
+ Child status, or `None`.
150
+ """
151
+ ...
152
+
153
+ @property
154
+ def stdout(self) -> bytes:
155
+ """Return bounded child standard-output bytes.
156
+
157
+ Returns:
158
+ Raw stdout, at most the documented stdout limit.
159
+ """
160
+ ...
161
+
162
+
163
+ class FinalResponse(Protocol):
164
+ """Carry the wrapper's final native response and exit status."""
165
+
166
+ @property
167
+ def exit_code(self) -> int:
168
+ """Return the wrapper process exit status.
169
+
170
+ Returns:
171
+ Exit status the wrapper returns after writing `stdout`.
172
+ """
173
+ ...
174
+
175
+ @property
176
+ def stdout(self) -> bytes:
177
+ """Return the exact native response bytes.
178
+
179
+ Returns:
180
+ Bytes written unchanged to the wrapper's standard output.
181
+ """
182
+ ...
183
+
184
+
185
+ class Diagnostics(Protocol):
186
+ """Write bounded, labeled developer diagnostics."""
187
+
188
+ def write_global(self, message: str) -> None:
189
+ """Write a diagnostic that does not belong to one step.
190
+
191
+ Args:
192
+ message: Safe user-facing diagnostic text.
193
+ """
194
+ ...
195
+
196
+ def write_step(self, step: Step, message: str) -> None:
197
+ """Write one labeled step diagnostic.
198
+
199
+ Args:
200
+ step: Child associated with the message.
201
+ message: Safe user-facing diagnostic text.
202
+ """
203
+ ...
204
+
205
+
206
+ class HostAdapter(Protocol):
207
+ """Compose ordered child results for one validated native invocation.
208
+
209
+ Before each child, the runner obtains the current native input through
210
+ `step_input()`. It passes each result to `consume()` in order, stops
211
+ immediately when `consume()` returns `'stop'`, and then calls `finalize()`
212
+ for the terminal response; otherwise it calls `finalize()` after the
213
+ configured steps are exhausted. `should_run_steps` may bypass the child
214
+ chain and finalize immediately.
215
+ """
216
+
217
+ def consume(self, step: Step, result: StepResult) -> ChainControl:
218
+ """Apply one child result and choose whether the runner continues.
219
+
220
+ Args:
221
+ step: Child that produced `result`.
222
+ result: Bounded process outcome to interpret.
223
+
224
+ Returns:
225
+ `'continue'` to start the next step, `'stop'` to finalize now.
226
+ """
227
+ ...
228
+
229
+ @property
230
+ def event(self) -> str:
231
+ """Return the validated native event.
232
+
233
+ Returns:
234
+ One of the host's `events`.
235
+ """
236
+ ...
237
+
238
+ def finalize(self) -> FinalResponse:
239
+ """Return the final native host response.
240
+
241
+ Returns:
242
+ Exactly one host response and wrapper exit status.
243
+ """
244
+ ...
245
+
246
+ @property
247
+ def should_run_steps(self) -> bool:
248
+ """Return whether this invocation should execute child steps.
249
+
250
+ Returns:
251
+ `True` when the runner should execute the configured chain.
252
+ """
253
+ ...
254
+
255
+ def step_input(self) -> bytes:
256
+ """Return native JSON input for the next child.
257
+
258
+ Returns:
259
+ Current native payload as strict JSON bytes.
260
+ """
261
+ ...
262
+
263
+
264
+ class DoctorLoader(Protocol):
265
+ """Inspect one host's hook configuration without executing anything."""
266
+
267
+ def analyze_registration(
268
+ self,
269
+ registration: Registration,
270
+ inspected: InspectedCommand,
271
+ *,
272
+ windows: bool,
273
+ ) -> tuple[Finding, ...]:
274
+ """Return host-specific findings for one inspected registration.
275
+
276
+ Args:
277
+ registration: Accepted registration.
278
+ inspected: Static inspection of the registration's command.
279
+ windows: Whether Windows launch semantics apply.
280
+
281
+ Returns:
282
+ Findings in report order; empty when the host adds none.
283
+ """
284
+ ...
285
+
286
+ def discover_sources(
287
+ self,
288
+ home: Path,
289
+ cwd: Path,
290
+ system_root: Path,
291
+ ) -> tuple[ConfigSource, ...]:
292
+ """Return every configuration source in deterministic inspection order.
293
+
294
+ Args:
295
+ home: Home directory used to locate user configuration.
296
+ cwd: Working directory used to locate project configuration.
297
+ system_root: Filesystem root used to locate system configuration.
298
+
299
+ Returns:
300
+ Candidate sources, existing or not, in inspection order.
301
+ """
302
+ ...
303
+
304
+ def inspection_limitations(self) -> tuple[str, ...]:
305
+ """Return configuration sources static inspection cannot establish.
306
+
307
+ Returns:
308
+ Stable limitation descriptions in report order.
309
+ """
310
+ ...
311
+
312
+ def load_registrations(
313
+ self,
314
+ sources: tuple[ConfigSource, ...],
315
+ ) -> tuple[tuple[Registration, ...], tuple[Finding, ...]]:
316
+ """Extract registrations and structural findings in source order.
317
+
318
+ Args:
319
+ sources: Sources returned by `discover_sources`.
320
+
321
+ Returns:
322
+ Accepted registrations and source-isolated findings.
323
+ """
324
+ ...
325
+
326
+
327
+ class HostSpec(Protocol):
328
+ """Describe one host: its events, adapter factory, and doctor loader.
329
+
330
+ An entry point in the `sequential_hooks.hosts` group names a zero-argument
331
+ callable, normally a class, that constructs an object satisfying this
332
+ Protocol. `api_version` must equal `API_VERSION`.
333
+ """
334
+
335
+ def adapter(
336
+ self,
337
+ event: str,
338
+ raw_input: bytes,
339
+ payload: JsonObject,
340
+ diagnostics: Diagnostics,
341
+ *,
342
+ allow_on_failure: bool,
343
+ ) -> HostAdapter:
344
+ """Validate native input and construct the adapter for one invocation.
345
+
346
+ Args:
347
+ event: Trusted event, one of `events`.
348
+ raw_input: Original native strict JSON hook input.
349
+ payload: Parsed native strict JSON object.
350
+ diagnostics: Bounded developer diagnostic sink.
351
+ allow_on_failure: Whether approved pre-tool failures may continue.
352
+
353
+ Returns:
354
+ Adapter ready to drive the chain.
355
+
356
+ Raises:
357
+ HostInputError: If the payload is not valid input for `event`.
358
+ """
359
+ ...
360
+
361
+ @property
362
+ def api_version(self) -> int:
363
+ """Return the contract version this host implements.
364
+
365
+ Returns:
366
+ The `API_VERSION` the host was written against.
367
+ """
368
+ ...
369
+
370
+ def configuration_response(
371
+ self,
372
+ event: str,
373
+ reason: str,
374
+ diagnostics: Diagnostics,
375
+ ) -> FinalResponse:
376
+ """Return the host's safe fail-closed answer to a configuration error.
377
+
378
+ Args:
379
+ event: Trusted event, one of `events`.
380
+ reason: Wrapper-owned diagnostic published after response
381
+ resolution and omitted from native response.
382
+ diagnostics: Bounded developer diagnostic sink.
383
+
384
+ Returns:
385
+ Native response computable from the host and event alone.
386
+ """
387
+ ...
388
+
389
+ def doctor_loader(self) -> DoctorLoader:
390
+ """Return the loader that inspects this host's configuration.
391
+
392
+ Returns:
393
+ Static configuration loader for `doctor`.
394
+ """
395
+ ...
396
+
397
+ @property
398
+ def events(self) -> Sequence[str]:
399
+ """Return the native events this host serves.
400
+
401
+ Returns:
402
+ Event names accepted by `adapter` and `configuration_response`.
403
+ """
404
+ ...
405
+
406
+ @property
407
+ def name(self) -> str:
408
+ """Return the host name selected by `--agent`.
409
+
410
+ Returns:
411
+ Entry-point name.
412
+ """
413
+ ...
414
+
415
+ def payload_event(self, payload: Mapping[str, object]) -> str | None:
416
+ """Return the event a payload carries, for hosts that do not require `--event`.
417
+
418
+ Args:
419
+ payload: Parsed native strict JSON object.
420
+
421
+ Returns:
422
+ One of `events`, or `None` when the payload carries no recognizable
423
+ event.
424
+ """
425
+ ...
426
+
427
+ @property
428
+ def requires_event(self) -> bool:
429
+ """Return whether the host payload carries no event name.
430
+
431
+ Returns:
432
+ `True` when `--event` is required and `payload_event` is unused.
433
+ """
434
+ ...
@@ -0,0 +1,78 @@
1
+ """Provide shared configuration inspection primitives for host plugins."""
2
+
3
+ import math
4
+ from collections.abc import Iterable
5
+ from pathlib import Path
6
+
7
+ from sequential_hooks.hosts._records import SourceKind
8
+
9
+
10
+ def deduplicate_source_paths(
11
+ paths: Iterable[tuple[Path, SourceKind]],
12
+ ) -> list[tuple[Path, SourceKind]]:
13
+ """Drop later duplicates of one candidate path, keeping the first scope.
14
+
15
+ Args:
16
+ paths: Candidate files with scopes in emission order.
17
+
18
+ Returns:
19
+ The same order with only the first occurrence of each path.
20
+ """
21
+ seen: set[Path] = set()
22
+ unique: list[tuple[Path, SourceKind]] = []
23
+ for path, kind in paths:
24
+ if path in seen:
25
+ continue
26
+ seen.add(path)
27
+ unique.append((path, kind))
28
+ return unique
29
+
30
+
31
+ def parse_timeout(value: object, *, positive: bool) -> float | None:
32
+ """Convert one finite numeric timeout under a caller-owned sign rule.
33
+
34
+ Args:
35
+ value: Candidate timeout value.
36
+ positive: Whether the timeout must be greater than zero.
37
+
38
+ Returns:
39
+ The finite converted timeout, or `None` when the value is invalid.
40
+ """
41
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
42
+ return None
43
+ try:
44
+ parsed = float(value)
45
+ except (OverflowError, ValueError):
46
+ return None
47
+ if not math.isfinite(parsed) or (positive and parsed <= 0):
48
+ return None
49
+ return parsed
50
+
51
+
52
+ def project_layers(cwd: Path) -> tuple[Path, ...]:
53
+ """Return structural project layers without invoking Git.
54
+
55
+ Args:
56
+ cwd: Directory whose enclosing project layers are required.
57
+
58
+ Returns:
59
+ Project root through `cwd`, or only `cwd` when no Git root exists.
60
+ """
61
+ resolved = cwd.resolve()
62
+ root = next(
63
+ (
64
+ candidate
65
+ for candidate in (resolved, *resolved.parents)
66
+ if (candidate / '.git').exists()
67
+ ),
68
+ None,
69
+ )
70
+ if root is None:
71
+ return (resolved,)
72
+ relative = resolved.relative_to(root)
73
+ layers = [root]
74
+ current = root
75
+ for part in relative.parts:
76
+ current /= part
77
+ layers.append(current)
78
+ return tuple(layers)
@@ -0,0 +1,150 @@
1
+ """Encode and decode strict native hook JSON objects."""
2
+
3
+ import json
4
+ import math
5
+ from typing import cast
6
+
7
+ from sequential_hooks.hosts._contract import JsonObject, JsonValue
8
+
9
+ _MAX_JSON_INTEGER_DIGITS = 4_300
10
+
11
+
12
+ class JsonProtocolError(ValueError):
13
+ """Report a strict hook JSON encoding or decoding failure."""
14
+
15
+
16
+ class JsonObjectExpectedError(JsonProtocolError):
17
+ """Raised when valid protocol JSON is not an object."""
18
+
19
+
20
+ class _JsonIntegerTooLongError(ValueError):
21
+ """Identify a JSON integer outside the bounded digit count."""
22
+
23
+
24
+ def _object_from_pairs(pairs: list[tuple[str, object]]) -> dict[str, object]:
25
+ """Build an object while rejecting duplicate keys."""
26
+ result: dict[str, object] = {}
27
+ for key, value in pairs:
28
+ if key in result:
29
+ raise JsonProtocolError(f'duplicate JSON key: {key}')
30
+ result[key] = value
31
+ return result
32
+
33
+
34
+ def _parse_integer(value: str) -> int:
35
+ """Parse one JSON integer within the shared digit bound."""
36
+ if len(value.removeprefix('-')) > _MAX_JSON_INTEGER_DIGITS:
37
+ raise _JsonIntegerTooLongError
38
+ try:
39
+ return int(value)
40
+ except ValueError as error:
41
+ raise _JsonIntegerTooLongError from error
42
+
43
+
44
+ def _reject_constant(value: str) -> object:
45
+ """Reject non-standard JSON numeric constants."""
46
+ raise JsonProtocolError(f'non-standard JSON number: {value}')
47
+
48
+
49
+ def _to_json_value(value: object) -> JsonValue:
50
+ """Validate and narrow a decoded JSON value."""
51
+ if value is None or isinstance(value, bool | int | str):
52
+ return value
53
+ if isinstance(value, float):
54
+ if not math.isfinite(value):
55
+ raise JsonProtocolError('non-finite JSON number')
56
+ return value
57
+ if isinstance(value, list):
58
+ return [_to_json_value(item) for item in cast('list[object]', value)]
59
+ if isinstance(value, dict):
60
+ items = cast('dict[object, object]', value)
61
+ return {str(key): _to_json_value(item) for key, item in items.items()}
62
+ raise JsonProtocolError(f'unsupported JSON value: {type(value).__name__}')
63
+
64
+
65
+ def dump_object(value: JsonObject) -> bytes:
66
+ """Encode one native JSON object with a trailing newline.
67
+
68
+ Args:
69
+ value: JSON object to encode without sorting its keys.
70
+
71
+ Returns:
72
+ Compact UTF-8 bytes ending in one newline.
73
+
74
+ Raises:
75
+ JsonProtocolError: If JSON serialization rejects a value, including a
76
+ non-finite number or an integer beyond the interpreter's conversion
77
+ limit.
78
+ UnicodeEncodeError: If a string value cannot be encoded as UTF-8.
79
+ """
80
+ try:
81
+ text = json.dumps(
82
+ value,
83
+ ensure_ascii=False,
84
+ allow_nan=False,
85
+ separators=(',', ':'),
86
+ )
87
+ except (TypeError, ValueError) as error:
88
+ raise JsonProtocolError(str(error)) from error
89
+ return f'{text}\n'.encode()
90
+
91
+
92
+ def load_object_text(text: str) -> JsonObject:
93
+ """Decode exactly one strict JSON object from text.
94
+
95
+ Args:
96
+ text: Native hook protocol text. Integer literals may contain at most
97
+ 4,300 digits and must also fit the interpreter's conversion limit.
98
+
99
+ Returns:
100
+ Decoded object with insertion order preserved.
101
+
102
+ Raises:
103
+ JsonObjectExpectedError: If valid protocol JSON has a non-object
104
+ top-level value.
105
+ JsonProtocolError: If the text is invalid or ambiguous JSON, contains
106
+ non-finite numbers, exceeds the integer limit, or nests beyond the
107
+ supported depth.
108
+ """
109
+ try:
110
+ decoded = cast(
111
+ 'object',
112
+ json.loads(
113
+ text,
114
+ object_pairs_hook=_object_from_pairs,
115
+ parse_constant=_reject_constant,
116
+ parse_int=_parse_integer,
117
+ ),
118
+ )
119
+ value = _to_json_value(decoded)
120
+ except (json.JSONDecodeError, _JsonIntegerTooLongError) as error:
121
+ raise JsonProtocolError(str(error)) from error
122
+ except RecursionError as error:
123
+ raise JsonProtocolError('JSON nesting exceeds the supported depth') from error
124
+ if not isinstance(value, dict):
125
+ raise JsonObjectExpectedError('hook input must be a JSON object')
126
+ return value
127
+
128
+
129
+ def load_object(data: bytes) -> JsonObject:
130
+ """Decode exactly one strict UTF-8 JSON object.
131
+
132
+ Args:
133
+ data: Native hook protocol bytes. Integer literals may contain at most
134
+ 4,300 digits and must also fit the interpreter's conversion limit.
135
+
136
+ Returns:
137
+ Decoded object with insertion order preserved.
138
+
139
+ Raises:
140
+ JsonObjectExpectedError: If valid protocol JSON has a non-object
141
+ top-level value.
142
+ JsonProtocolError: If the bytes are invalid UTF-8 or invalid or
143
+ ambiguous JSON, contain non-finite numbers, exceed the integer
144
+ limit, or nest beyond the supported depth.
145
+ """
146
+ try:
147
+ text = data.decode('utf-8')
148
+ except UnicodeDecodeError as error:
149
+ raise JsonProtocolError(str(error)) from error
150
+ return load_object_text(text)