ai-push-hooks 0.2.1 → 0.3.1

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 (37) hide show
  1. package/CHANGELOG.md +73 -1
  2. package/README.md +80 -525
  3. package/SECURITY.md +102 -14
  4. package/ai-push-hooks.toml +9 -2
  5. package/bin/ai-push-hooks.js +6 -6
  6. package/package.json +3 -2
  7. package/pyproject.toml +1 -1
  8. package/src/ai_push_hooks/artifacts.py +67 -13
  9. package/src/ai_push_hooks/config.py +575 -22
  10. package/src/ai_push_hooks/engine.py +116 -7
  11. package/src/ai_push_hooks/executors/apply.py +75 -36
  12. package/src/ai_push_hooks/executors/ask.py +224 -0
  13. package/src/ai_push_hooks/executors/exec.py +17 -801
  14. package/src/ai_push_hooks/executors/runner_workflow.py +478 -0
  15. package/src/ai_push_hooks/executors/runners/__init__.py +78 -0
  16. package/src/ai_push_hooks/executors/runners/claude.py +286 -0
  17. package/src/ai_push_hooks/executors/runners/codex.py +254 -0
  18. package/src/ai_push_hooks/executors/runners/command.py +178 -0
  19. package/src/ai_push_hooks/executors/runners/contracts.py +597 -0
  20. package/src/ai_push_hooks/executors/runners/opencode.py +528 -0
  21. package/src/ai_push_hooks/executors/runners/opencode_support.py +276 -0
  22. package/src/ai_push_hooks/executors/runners/process.py +464 -0
  23. package/src/ai_push_hooks/executors/runners/registry.py +117 -0
  24. package/src/ai_push_hooks/executors/step_commands.py +478 -0
  25. package/src/ai_push_hooks/git_utils.py +834 -0
  26. package/src/ai_push_hooks/hook.py +1 -1
  27. package/src/ai_push_hooks/modules/beads.py +1 -1
  28. package/src/ai_push_hooks/modules/docs.py +129 -89
  29. package/src/ai_push_hooks/modules/pr.py +1 -1
  30. package/src/ai_push_hooks/plugin_loader.py +422 -0
  31. package/src/ai_push_hooks/plugins.py +134 -0
  32. package/src/ai_push_hooks/prompts_builtin.py +9 -2
  33. package/src/ai_push_hooks/types.py +407 -75
  34. package/vendor/README.md +15 -0
  35. package/vendor/requirements.txt +1 -0
  36. package/vendor/tomli-2.4.0-py3-none-any.whl +0 -0
  37. package/src/ai_push_hooks/executors/llm.py +0 -624
@@ -0,0 +1,464 @@
1
+ """Common shell-free child-process execution for runner adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import os
7
+ import pathlib
8
+ import signal
9
+ import stat
10
+ import subprocess
11
+ import threading
12
+ import time
13
+ from collections.abc import Mapping, Sequence
14
+ from dataclasses import dataclass
15
+
16
+ from ...paths import path_is_link_or_reparse
17
+ from .contracts import (
18
+ RunnerError,
19
+ RunnerExecutableNotFoundError,
20
+ RunnerSignalError,
21
+ RunnerTimeoutError,
22
+ bounded_diagnostic,
23
+ )
24
+
25
+
26
+ PROCESS_CHUNK_BYTES = 64 * 1024
27
+ # Agent CLIs commonly emit multi-megabyte JSONL event streams. This remains a
28
+ # hard per-stream bound; callers with a tighter budget can override it.
29
+ DEFAULT_MAX_OUTPUT_BYTES = 16 * 1024 * 1024
30
+ PROCESS_CLEANUP_GRACE_SECONDS = 1.0
31
+
32
+
33
+ @dataclass(frozen=True, repr=False)
34
+ class ProcessResult:
35
+ """Bounded child output. Streams are never included in ``repr``."""
36
+
37
+ returncode: int
38
+ stdout: str
39
+ stderr: str
40
+ stdout_truncated: bool = False
41
+ stderr_truncated: bool = False
42
+ # Keep the original bounded bytes for callers that persist process output.
43
+ # The text fields above intentionally retain the runner's surrogateescape
44
+ # decoding contract.
45
+ stdout_bytes: bytes = b""
46
+ stderr_bytes: bytes = b""
47
+
48
+ def __repr__(self) -> str:
49
+ return (
50
+ "ProcessResult("
51
+ f"returncode={self.returncode!r}, stdout=<redacted>, stderr=<redacted>, "
52
+ f"stdout_truncated={self.stdout_truncated!r}, stderr_truncated={self.stderr_truncated!r})"
53
+ )
54
+
55
+
56
+ def _read_bounded(
57
+ stream: object,
58
+ limit: int,
59
+ output: bytearray,
60
+ truncated: list[bool],
61
+ output_lock: threading.Lock,
62
+ output_limit_reached: threading.Event,
63
+ ) -> None:
64
+ read = getattr(stream, "read")
65
+ try:
66
+ while True:
67
+ try:
68
+ chunk = read(PROCESS_CHUNK_BYTES)
69
+ except (OSError, ValueError):
70
+ return
71
+ if not chunk:
72
+ return
73
+ with output_lock:
74
+ remaining = limit - len(output)
75
+ if remaining > 0:
76
+ output.extend(chunk[:remaining])
77
+ if len(chunk) > remaining:
78
+ truncated[0] = True
79
+ output_limit_reached.set()
80
+ finally:
81
+ _close_pipe(stream)
82
+
83
+
84
+ def _signal_process_group(process_group_id: int, signum: int) -> None:
85
+ try:
86
+ os.killpg(process_group_id, signum)
87
+ except (OSError, ProcessLookupError):
88
+ return
89
+
90
+
91
+ def _process_group_exists(process_group_id: int) -> bool:
92
+ try:
93
+ os.killpg(process_group_id, 0)
94
+ except (OSError, ProcessLookupError):
95
+ return False
96
+ return True
97
+
98
+
99
+ def _wait_process_until(process: subprocess.Popen[bytes], deadline: float) -> None:
100
+ while process.poll() is None:
101
+ remaining = deadline - time.monotonic()
102
+ if remaining <= 0:
103
+ return
104
+ try:
105
+ process.wait(timeout=min(remaining, 0.05))
106
+ except subprocess.TimeoutExpired:
107
+ continue
108
+
109
+
110
+ def _stop_process(process: subprocess.Popen[bytes], deadline: float) -> None:
111
+ """Terminate this invocation's process group within one shared deadline."""
112
+
113
+ process_group_id = process.pid if os.name == "posix" else None
114
+ if process_group_id is not None:
115
+ # start_new_session=True makes the leader's pid the private process
116
+ # group id. Do not gate this on leader.poll(): descendants can retain
117
+ # the pipes after the leader has already exited.
118
+ _signal_process_group(process_group_id, signal.SIGTERM)
119
+ elif process.poll() is None:
120
+ process.terminate()
121
+
122
+ _wait_process_until(process, deadline)
123
+
124
+ if process_group_id is not None:
125
+ while _process_group_exists(process_group_id):
126
+ remaining = deadline - time.monotonic()
127
+ if remaining <= 0:
128
+ _signal_process_group(process_group_id, signal.SIGKILL)
129
+ try:
130
+ process.wait(timeout=0.1)
131
+ except subprocess.TimeoutExpired:
132
+ pass
133
+ break
134
+ time.sleep(min(0.01, remaining))
135
+ elif process.poll() is None:
136
+ remaining = deadline - time.monotonic()
137
+ if remaining > 0:
138
+ try:
139
+ process.wait(timeout=remaining)
140
+ except subprocess.TimeoutExpired:
141
+ process.kill()
142
+ try:
143
+ process.wait(timeout=0.1)
144
+ except subprocess.TimeoutExpired:
145
+ pass
146
+
147
+
148
+ def _close_pipe(stream: object) -> None:
149
+ """Close an unbuffered pipe through its owning stream object."""
150
+
151
+ try:
152
+ getattr(stream, "close")()
153
+ except (OSError, ValueError, AttributeError):
154
+ return
155
+
156
+
157
+ def _finish_capture(
158
+ streams: tuple[object, ...],
159
+ threads: tuple[threading.Thread, ...],
160
+ deadline: float,
161
+ ) -> None:
162
+ """Bound all joins and force-close pipe fds if a reader remains blocked."""
163
+
164
+ for thread in threads:
165
+ remaining = deadline - time.monotonic()
166
+ if remaining <= 0:
167
+ break
168
+ thread.join(timeout=remaining)
169
+ if any(thread.is_alive() for thread in threads):
170
+ for stream in streams:
171
+ _close_pipe(stream)
172
+ for thread in threads:
173
+ remaining = deadline - time.monotonic()
174
+ if remaining <= 0:
175
+ break
176
+ thread.join(timeout=remaining)
177
+ # Popen creates FileIO objects because bufsize=0. FileIO.close() does not
178
+ # flush buffered data and atomically relinquishes descriptor ownership, so
179
+ # repeated cleanup cannot later close a descriptor reused by another child.
180
+ for stream in streams:
181
+ _close_pipe(stream)
182
+
183
+
184
+ def _captured_result(
185
+ returncode: int,
186
+ stdout: bytearray,
187
+ stderr: bytearray,
188
+ stdout_truncated: list[bool],
189
+ stderr_truncated: list[bool],
190
+ stdout_lock: threading.Lock,
191
+ stderr_lock: threading.Lock,
192
+ ) -> ProcessResult:
193
+ """Snapshot bounded streams for an exception without exposing them in it."""
194
+
195
+ with stdout_lock:
196
+ stdout_text = bytes(stdout).decode("utf-8", errors="surrogateescape")
197
+ with stderr_lock:
198
+ stderr_text = bytes(stderr).decode("utf-8", errors="surrogateescape")
199
+ return ProcessResult(
200
+ returncode=returncode,
201
+ stdout=stdout_text,
202
+ stderr=stderr_text,
203
+ stdout_truncated=stdout_truncated[0],
204
+ stderr_truncated=stderr_truncated[0],
205
+ stdout_bytes=bytes(stdout),
206
+ stderr_bytes=bytes(stderr),
207
+ )
208
+
209
+
210
+ def run_process(
211
+ argv: Sequence[str],
212
+ *,
213
+ cwd: pathlib.Path,
214
+ input_text: str | None = None,
215
+ input_path: pathlib.Path | None = None,
216
+ timeout_seconds: float,
217
+ env: Mapping[str, str] | None = None,
218
+ max_output_bytes: int = DEFAULT_MAX_OUTPUT_BYTES,
219
+ max_stderr_bytes: int | None = None,
220
+ ) -> ProcessResult:
221
+ """Run an argv vector directly, with bounded concurrent stream capture.
222
+
223
+ ``shell=False`` is explicit and prompt text is written only to stdin. The
224
+ environment is accepted for the child but is never inspected or included
225
+ in errors. Non-zero exits are returned for adapter-specific normalization;
226
+ timeout and signal termination are raised as distinct fail-closed errors.
227
+ """
228
+
229
+ if not isinstance(argv, (tuple, list)) or not argv or any(
230
+ not isinstance(argument, str) or not argument or "\x00" in argument for argument in argv
231
+ ):
232
+ raise RunnerError("runner command must be a non-empty NUL-free argv vector")
233
+ if not isinstance(cwd, pathlib.Path):
234
+ cwd = pathlib.Path(cwd)
235
+ if not cwd.is_dir():
236
+ raise RunnerError("runner cwd must be an existing directory")
237
+ if (
238
+ isinstance(timeout_seconds, bool)
239
+ or not isinstance(timeout_seconds, (int, float))
240
+ or not math.isfinite(timeout_seconds)
241
+ or timeout_seconds <= 0
242
+ ):
243
+ raise RunnerError("runner timeout must be a finite number greater than zero")
244
+ if (
245
+ isinstance(max_output_bytes, bool)
246
+ or not isinstance(max_output_bytes, int)
247
+ or max_output_bytes < 0
248
+ ):
249
+ raise RunnerError("runner output bound must not be negative")
250
+ stderr_limit = max_output_bytes if max_stderr_bytes is None else max_stderr_bytes
251
+ if (
252
+ isinstance(stderr_limit, bool)
253
+ or not isinstance(stderr_limit, int)
254
+ or stderr_limit < 0
255
+ ):
256
+ raise RunnerError("runner stderr output bound must not be negative")
257
+ if input_text is not None and input_path is not None:
258
+ raise RunnerError("runner input_text and input_path are mutually exclusive")
259
+ input_file = None
260
+ if input_path is not None:
261
+ if not isinstance(input_path, pathlib.Path):
262
+ input_path = pathlib.Path(input_path)
263
+ if path_is_link_or_reparse(input_path):
264
+ raise RunnerError("runner input file must not be a symlink or reparse point")
265
+ descriptor = -1
266
+ try:
267
+ # O_NONBLOCK prevents opening a FIFO from waiting for a writer;
268
+ # O_NOFOLLOW closes the symlink race between validation and open.
269
+ flags = (
270
+ os.O_RDONLY
271
+ | os.O_NONBLOCK
272
+ | getattr(os, "O_CLOEXEC", 0)
273
+ | getattr(os, "O_NOFOLLOW", 0)
274
+ )
275
+ descriptor = os.open(input_path, flags)
276
+ if not stat.S_ISREG(os.fstat(descriptor).st_mode):
277
+ raise RunnerError("runner input file must be an ordinary regular file")
278
+ input_file = os.fdopen(descriptor, "rb", buffering=0)
279
+ descriptor = -1
280
+ except RunnerError:
281
+ raise
282
+ except (OSError, ValueError) as exc:
283
+ raise RunnerError("runner input file could not be opened safely") from exc
284
+ finally:
285
+ if descriptor >= 0:
286
+ os.close(descriptor)
287
+
288
+ popen_kwargs: dict[str, object] = {
289
+ "cwd": cwd,
290
+ "stdin": subprocess.PIPE,
291
+ "stdout": subprocess.PIPE,
292
+ "stderr": subprocess.PIPE,
293
+ "shell": False,
294
+ "env": dict(env) if env is not None else None,
295
+ # Buffered pipe wrappers cannot safely be bypassed with os.close(): the
296
+ # wrapper still owns the descriptor and may close a later reused fd in
297
+ # its destructor. Unbuffered FileIO makes close bounded and ownership
298
+ # explicit while the reader threads retain concurrent stream draining.
299
+ "bufsize": 0,
300
+ }
301
+ if os.name == "posix":
302
+ popen_kwargs["start_new_session"] = True
303
+ try:
304
+ process = subprocess.Popen(list(argv), **popen_kwargs)
305
+ except FileNotFoundError as exc:
306
+ if input_file is not None:
307
+ input_file.close()
308
+ raise RunnerExecutableNotFoundError(
309
+ "runner executable was not found",
310
+ details=bounded_diagnostic(argv[0]),
311
+ ) from exc
312
+ except OSError as exc:
313
+ if input_file is not None:
314
+ input_file.close()
315
+ raise RunnerError("runner process could not be started", details=type(exc).__name__) from exc
316
+
317
+ if process.stdout is None or process.stderr is None or process.stdin is None:
318
+ _stop_process(process, time.monotonic() + PROCESS_CLEANUP_GRACE_SECONDS)
319
+ if input_file is not None:
320
+ input_file.close()
321
+ raise RunnerError("runner process pipes were not available")
322
+
323
+ stdout = bytearray()
324
+ stderr = bytearray()
325
+ stdout_lock = threading.Lock()
326
+ stderr_lock = threading.Lock()
327
+ stdout_truncated = [False]
328
+ stderr_truncated = [False]
329
+ output_limit_reached = threading.Event()
330
+ stdout_thread = threading.Thread(
331
+ target=_read_bounded,
332
+ args=(
333
+ process.stdout,
334
+ max_output_bytes,
335
+ stdout,
336
+ stdout_truncated,
337
+ stdout_lock,
338
+ output_limit_reached,
339
+ ),
340
+ daemon=True,
341
+ )
342
+ stderr_thread = threading.Thread(
343
+ target=_read_bounded,
344
+ args=(
345
+ process.stderr,
346
+ stderr_limit,
347
+ stderr,
348
+ stderr_truncated,
349
+ stderr_lock,
350
+ output_limit_reached,
351
+ ),
352
+ daemon=True,
353
+ )
354
+ stdout_thread.start()
355
+ stderr_thread.start()
356
+
357
+ input_bytes = None if input_text is None else input_text.encode("utf-8", errors="surrogateescape")
358
+
359
+ def write_input() -> None:
360
+ try:
361
+ if input_file is not None:
362
+ while True:
363
+ chunk = input_file.read(PROCESS_CHUNK_BYTES)
364
+ if not chunk:
365
+ break
366
+ offset = 0
367
+ while offset < len(chunk):
368
+ written = process.stdin.write(chunk[offset:])
369
+ if not written:
370
+ return
371
+ offset += written
372
+ elif input_bytes:
373
+ offset = 0
374
+ while offset < len(input_bytes):
375
+ written = process.stdin.write(input_bytes[offset:])
376
+ if not written:
377
+ break
378
+ offset += written
379
+ except (BrokenPipeError, OSError, ValueError):
380
+ pass
381
+ finally:
382
+ if input_file is not None:
383
+ _close_pipe(input_file)
384
+ _close_pipe(process.stdin)
385
+
386
+ input_thread = threading.Thread(target=write_input, daemon=True)
387
+ input_thread.start()
388
+ process_exited = threading.Event()
389
+ process_returncode: list[int | None] = [None]
390
+
391
+ def wait_for_process_exit() -> None:
392
+ process_returncode[0] = process.wait()
393
+ process_exited.set()
394
+
395
+ process_wait_thread = threading.Thread(target=wait_for_process_exit, daemon=True)
396
+ process_wait_thread.start()
397
+ returncode: int | None = None
398
+ timeout_cause: subprocess.TimeoutExpired | None = None
399
+ output_limit_cause = False
400
+ try:
401
+ deadline = time.monotonic() + timeout_seconds
402
+ while True:
403
+ remaining = deadline - time.monotonic()
404
+ if remaining <= 0:
405
+ timeout_cause = subprocess.TimeoutExpired(list(argv), timeout_seconds)
406
+ break
407
+ if output_limit_reached.is_set():
408
+ # A short grace period preserves the natural exit status for
409
+ # commands that wrote their final bounded chunk and were
410
+ # already exiting. A still-live child is stopped below
411
+ # rather than allowed to drain discarded output until timeout.
412
+ if process_exited.wait(timeout=min(remaining, 0.05)):
413
+ returncode = process_returncode[0]
414
+ else:
415
+ output_limit_cause = True
416
+ break
417
+ if process_exited.wait(timeout=min(remaining, 0.05)):
418
+ returncode = process_returncode[0]
419
+ break
420
+ finally:
421
+ cleanup_deadline = time.monotonic() + PROCESS_CLEANUP_GRACE_SECONDS
422
+ _stop_process(process, cleanup_deadline)
423
+ _finish_capture(
424
+ (process.stdin, process.stdout, process.stderr),
425
+ (input_thread, stdout_thread, stderr_thread, process_wait_thread),
426
+ cleanup_deadline,
427
+ )
428
+
429
+ if returncode is None:
430
+ returncode = process_returncode[0] if process_returncode[0] is not None else process.poll()
431
+ if timeout_cause is not None:
432
+ timeout_returncode = returncode if returncode is not None else -getattr(signal, "SIGKILL", 9)
433
+ process_result = _captured_result(
434
+ timeout_returncode,
435
+ stdout,
436
+ stderr,
437
+ stdout_truncated,
438
+ stderr_truncated,
439
+ stdout_lock,
440
+ stderr_lock,
441
+ )
442
+ error = RunnerTimeoutError("runner process exceeded its timeout")
443
+ setattr(error, "_process_result", process_result)
444
+ raise error from timeout_cause
445
+
446
+ if returncode is None: # pragma: no cover - process.wait either returns or raises
447
+ raise RunnerError("runner process returned no exit status")
448
+ process_result = _captured_result(
449
+ returncode,
450
+ stdout,
451
+ stderr,
452
+ stdout_truncated,
453
+ stderr_truncated,
454
+ stdout_lock,
455
+ stderr_lock,
456
+ )
457
+ if returncode < 0 and not output_limit_cause:
458
+ error = RunnerSignalError(
459
+ "runner process terminated by signal",
460
+ details=str(-returncode),
461
+ )
462
+ setattr(error, "_process_result", process_result)
463
+ raise error
464
+ return process_result
@@ -0,0 +1,117 @@
1
+ """Static, lazy registry for the four built-in runner adapter types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import threading
7
+ from dataclasses import dataclass
8
+ from types import ModuleType
9
+ from typing import Callable, Mapping
10
+
11
+ from .contracts import (
12
+ Runner,
13
+ RunnerAdapterUnavailableError,
14
+ RunnerContractError,
15
+ )
16
+
17
+
18
+ KNOWN_RUNNER_TYPES = ("opencode", "codex", "claude", "command")
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class LazyRunnerSpec:
23
+ """Import and construct an adapter only when that type is selected."""
24
+
25
+ module: str
26
+ factory_name: str = "create_runner"
27
+
28
+ def load(self) -> Runner:
29
+ try:
30
+ module: ModuleType = importlib.import_module(self.module)
31
+ except ModuleNotFoundError as exc:
32
+ if exc.name == self.module:
33
+ raise RunnerAdapterUnavailableError(
34
+ "selected runner adapter is not installed in this build"
35
+ ) from exc
36
+ raise RunnerAdapterUnavailableError(
37
+ "selected runner adapter could not load its dependency",
38
+ details=type(exc).__name__,
39
+ ) from exc
40
+ try:
41
+ factory: Callable[[], Runner] = getattr(module, self.factory_name)
42
+ except AttributeError as exc:
43
+ raise RunnerAdapterUnavailableError(
44
+ "selected runner adapter has no factory"
45
+ ) from exc
46
+ try:
47
+ runner = factory()
48
+ except RunnerAdapterUnavailableError:
49
+ raise
50
+ except Exception as exc: # noqa: BLE001
51
+ raise RunnerAdapterUnavailableError(
52
+ "selected runner adapter could not be constructed",
53
+ details=type(exc).__name__,
54
+ ) from exc
55
+ if not callable(getattr(runner, "run", None)):
56
+ raise RunnerAdapterUnavailableError("selected runner does not implement run(request)")
57
+ return runner
58
+
59
+
60
+ _DEFAULT_SPECS = {
61
+ "opencode": LazyRunnerSpec("ai_push_hooks.executors.runners.opencode"),
62
+ "codex": LazyRunnerSpec("ai_push_hooks.executors.runners.codex"),
63
+ "claude": LazyRunnerSpec("ai_push_hooks.executors.runners.claude"),
64
+ "command": LazyRunnerSpec("ai_push_hooks.executors.runners.command"),
65
+ }
66
+
67
+
68
+ class RunnerRegistry:
69
+ """A fixed registry; it intentionally has no runtime plugin registration API."""
70
+
71
+ def __init__(self, specs: Mapping[str, LazyRunnerSpec | Callable[[], Runner]] | None = None) -> None:
72
+ selected = _DEFAULT_SPECS if specs is None else dict(specs)
73
+ unknown = set(selected) - set(KNOWN_RUNNER_TYPES)
74
+ if unknown:
75
+ raise RunnerContractError(
76
+ "runner registry contains unsupported adapter types: " + ", ".join(sorted(unknown))
77
+ )
78
+ if set(selected) != set(KNOWN_RUNNER_TYPES):
79
+ raise RunnerContractError("runner registry must contain all four known adapter types")
80
+ self._specs = dict(selected)
81
+ self._loaded: dict[str, Runner] = {}
82
+ self._load_locks = {
83
+ runner_type: threading.Lock() for runner_type in KNOWN_RUNNER_TYPES
84
+ }
85
+
86
+ @property
87
+ def known_types(self) -> tuple[str, ...]:
88
+ return KNOWN_RUNNER_TYPES
89
+
90
+ def get(self, runner_type: str) -> Runner:
91
+ if runner_type not in self._specs:
92
+ raise RunnerContractError(f"unknown runner type: {runner_type!r}")
93
+ runner = self._loaded.get(runner_type)
94
+ if runner is not None:
95
+ return runner
96
+ # Initialization is serialized only for the selected adapter. Other
97
+ # runner types may load concurrently, which matters for capability
98
+ # probes such as Claude's --help check.
99
+ with self._load_locks[runner_type]:
100
+ runner = self._loaded.get(runner_type)
101
+ if runner is None:
102
+ spec = self._specs[runner_type]
103
+ runner = spec.load() if isinstance(spec, LazyRunnerSpec) else spec()
104
+ self._loaded[runner_type] = runner
105
+ return runner
106
+
107
+ def __contains__(self, runner_type: object) -> bool:
108
+ return runner_type in self._specs
109
+
110
+
111
+ DEFAULT_RUNNER_REGISTRY = RunnerRegistry()
112
+
113
+
114
+ def get_runner(runner_type: str, *, registry: RunnerRegistry = DEFAULT_RUNNER_REGISTRY) -> Runner:
115
+ """Resolve one of the four static adapter types lazily."""
116
+
117
+ return registry.get(runner_type)