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,342 @@
1
+ """Orchestrate deterministic, read-only host diagnostics."""
2
+
3
+ import importlib.metadata
4
+ import sys
5
+ from collections.abc import Iterable
6
+ from dataclasses import replace
7
+ from pathlib import Path
8
+ from typing import Protocol
9
+
10
+ from sequential_hooks._doctor._command import inspect_command, redact_command
11
+ from sequential_hooks._doctor._common import (
12
+ analyze_registration,
13
+ analyze_registration_pairs,
14
+ inspect_platform,
15
+ )
16
+ from sequential_hooks._doctor._types import (
17
+ ConfigSource,
18
+ DoctorReport,
19
+ Finding,
20
+ FindingSeverity,
21
+ PlatformReport,
22
+ Registration,
23
+ )
24
+ from sequential_hooks._executable import ExecutableStatus, resolve_executable
25
+ from sequential_hooks._registry import HostLoadError, load_host
26
+
27
+
28
+ class DoctorOutput(Protocol):
29
+ """Write doctor report text."""
30
+
31
+ def write(self, value: str, /) -> int:
32
+ """Write report text.
33
+
34
+ Args:
35
+ value: Text to write.
36
+
37
+ Returns:
38
+ The number of characters written.
39
+
40
+ Raises:
41
+ OSError: If writing report text fails.
42
+ """
43
+ ...
44
+
45
+ def writelines(self, lines: Iterable[str], /) -> None:
46
+ """Write report lines.
47
+
48
+ Args:
49
+ lines: Report lines to write in order.
50
+
51
+ Raises:
52
+ OSError: If writing report lines fails.
53
+ """
54
+ ...
55
+
56
+
57
+ def _installed_version() -> str:
58
+ """Return the installed package version or `0.0.0` without metadata."""
59
+ try:
60
+ return importlib.metadata.version('sequential-hooks')
61
+ except importlib.metadata.PackageNotFoundError:
62
+ return '0.0.0'
63
+
64
+
65
+ def _render_boolean(value: bool | None) -> str:
66
+ """Render one inspectable, unknown, or inapplicable state."""
67
+ if value is None:
68
+ return 'unknown'
69
+ return 'true' if value else 'false'
70
+
71
+
72
+ def _render_scalar(value: object) -> str:
73
+ """Return one ASCII-safe line scalar without losing code-point identity."""
74
+ rendered: list[str] = []
75
+ for character in str(value):
76
+ if character == '\\':
77
+ escaped = '\\\\'
78
+ elif ' ' <= character <= '~':
79
+ escaped = character
80
+ else:
81
+ escaped = character.encode('unicode_escape').decode('ascii')
82
+ rendered.append(escaped)
83
+ return ''.join(rendered)
84
+
85
+
86
+ def _render_findings(findings: tuple[Finding, ...], stream: DoctorOutput) -> None:
87
+ """Render source problems, hook groups, and cross-hook findings with exact corrections."""
88
+ stream.write('\nfindings:\n')
89
+ if not findings:
90
+ stream.write('- none\n')
91
+ return
92
+ for finding in findings:
93
+ stream.write(
94
+ f'- [{_render_scalar(finding.severity.value)}] '
95
+ f'{_render_scalar(finding.code)}: {_render_scalar(finding.message)}\n'
96
+ )
97
+ if finding.correction is not None:
98
+ stream.write(f' correction: {_render_scalar(finding.correction)}\n')
99
+
100
+
101
+ def _render_limitations(
102
+ platform: PlatformReport,
103
+ limitations: tuple[str, ...],
104
+ stream: DoctorOutput,
105
+ ) -> None:
106
+ """Render platform and host limitations in stable order."""
107
+ stream.write('\nlimitations:\n')
108
+ combined = (*platform.limitations, *limitations)
109
+ if not combined:
110
+ stream.write('- none\n')
111
+ return
112
+ stream.writelines(f'- {_render_scalar(limitation)}\n' for limitation in combined)
113
+
114
+
115
+ def _render_registrations(
116
+ registrations: tuple[Registration, ...],
117
+ stream: DoctorOutput,
118
+ *,
119
+ windows: bool,
120
+ ) -> None:
121
+ """Render registrations without exposing args or unredacted commands."""
122
+ stream.write('\nregistrations:\n')
123
+ if not registrations:
124
+ stream.write('- none\n')
125
+ return
126
+ for registration in registrations:
127
+ matcher = registration.matcher if registration.matcher is not None else 'any'
128
+ timeout = str(registration.timeout) if registration.timeout is not None else 'unknown'
129
+ registration_type = registration.type if registration.type is not None else 'unknown'
130
+ windows_launch = (
131
+ registration.windows_launch_semantics.value
132
+ if registration.windows_launch_semantics is not None
133
+ else 'unavailable'
134
+ )
135
+ stream.write(
136
+ f'- source: {_render_scalar(registration.source.path)}\n'
137
+ f' event: {_render_scalar(registration.event)}\n'
138
+ f' matcher: {_render_scalar(matcher)}\n'
139
+ f' command: {_render_scalar(redact_command(registration, windows=windows))}\n'
140
+ f' timeout: {_render_scalar(timeout)}\n'
141
+ f' enabled: {_render_scalar(_render_boolean(registration.enabled))}\n'
142
+ f' type: {_render_scalar(registration_type)}\n'
143
+ f' launch: {_render_scalar(registration.launch_semantics.value)}\n'
144
+ f' windows launch: {_render_scalar(windows_launch)}\n'
145
+ )
146
+
147
+
148
+ def _render_sources(sources: tuple[ConfigSource, ...], stream: DoctorOutput) -> None:
149
+ """Render every configuration source in discovery order."""
150
+ stream.write('\nconfiguration sources:\n')
151
+ if not sources:
152
+ stream.write('- none\n')
153
+ return
154
+ for source in sources:
155
+ state = 'exists' if source.exists else 'missing'
156
+ stream.write(
157
+ f'- [{_render_scalar(source.kind.value)}] {_render_scalar(source.path)}: {state}\n'
158
+ )
159
+
160
+
161
+ def _render_report(report: DoctorReport, stream: DoctorOutput) -> None:
162
+ """Render one deterministic, redaction-safe human report."""
163
+ wrapper = str(report.executable) if report.executable is not None else 'not found'
164
+ version = report.version if report.version is not None else 'unknown'
165
+ job_object = (
166
+ 'not applicable'
167
+ if report.platform.job_object_configurable is None
168
+ else _render_boolean(report.platform.job_object_configurable)
169
+ )
170
+ stream.write(
171
+ 'sequential-hooks doctor\n'
172
+ 'display: ASCII backslash escapes; \\\\ represents one literal backslash\n'
173
+ f'agent: {_render_scalar(report.host)}\n'
174
+ f'wrapper: {_render_scalar(wrapper)}\n'
175
+ f'version: {_render_scalar(version)}\n'
176
+ f'interpreter: {_render_scalar(report.platform.interpreter)}\n'
177
+ f'containment: {_render_scalar(report.platform.containment)}\n'
178
+ f'job object configurable: {_render_scalar(job_object)}\n'
179
+ )
180
+ _render_sources(report.sources, stream)
181
+ _render_registrations(report.registrations, stream, windows=sys.platform == 'win32')
182
+ _render_findings(report.findings, stream)
183
+ _render_limitations(report.platform, report.limitations, stream)
184
+
185
+
186
+ def _wrapper_finding(
187
+ status: ExecutableStatus,
188
+ *,
189
+ existing_codes: set[str],
190
+ ) -> Finding | None:
191
+ """Return one aggregate wrapper-resolution problem without duplicates."""
192
+ if status is ExecutableStatus.NOT_FOUND and 'wrapper_executable_missing' not in existing_codes:
193
+ return Finding(
194
+ FindingSeverity.ERROR,
195
+ 'wrapper_executable_missing',
196
+ 'wrapper executable cannot be resolved',
197
+ 'Install or configure a runnable wrapper executable.',
198
+ )
199
+ if (
200
+ status is ExecutableStatus.UNSUPPORTED_SHIM
201
+ and 'wrapper_executable_unsupported' not in existing_codes
202
+ ):
203
+ return Finding(
204
+ FindingSeverity.ERROR,
205
+ 'wrapper_executable_unsupported',
206
+ 'wrapper executable resolves to an unsupported Windows shim',
207
+ 'Replace the Windows batch shim with a real .exe or .com executable entry point.',
208
+ )
209
+ return None
210
+
211
+
212
+ def _report(
213
+ report: DoctorReport,
214
+ *,
215
+ path_value: str | None,
216
+ path_ext: str | None,
217
+ ) -> DoctorReport:
218
+ """Resolve the wrapper executable and complete one inspection report."""
219
+ resolution = resolve_executable(
220
+ 'sequential-hooks',
221
+ path_value=path_value,
222
+ windows=sys.platform == 'win32',
223
+ pathext_value=path_ext,
224
+ )
225
+ wrapper_finding = _wrapper_finding(
226
+ resolution.status,
227
+ existing_codes={finding.code for finding in report.findings},
228
+ )
229
+ findings = report.findings
230
+ if wrapper_finding is not None:
231
+ findings = (*findings, wrapper_finding)
232
+ return replace(report, executable=resolution.resolved, findings=findings)
233
+
234
+
235
+ def _inspect_host( # noqa: PLR0913
236
+ host: str,
237
+ *,
238
+ home: Path,
239
+ cwd: Path,
240
+ system_root: Path,
241
+ path_value: str | None,
242
+ path_ext: str | None,
243
+ ) -> DoctorReport:
244
+ """Inspect one host without executing or editing configuration."""
245
+ try:
246
+ spec = load_host(host)
247
+ except HostLoadError as error:
248
+ unavailable = Finding(
249
+ FindingSeverity.ERROR,
250
+ 'host_unavailable',
251
+ str(error),
252
+ 'Install an adapter package that registers this agent in the '
253
+ 'sequential_hooks.hosts entry-point group.',
254
+ )
255
+ return _report(
256
+ DoctorReport(
257
+ host, None, _installed_version(), inspect_platform(), (), (), (unavailable,), ()
258
+ ),
259
+ path_value=path_value,
260
+ path_ext=path_ext,
261
+ )
262
+ loader = spec.doctor_loader()
263
+ windows = sys.platform == 'win32'
264
+ sources = loader.discover_sources(home, cwd, system_root)
265
+ registrations, source_findings = loader.load_registrations(sources)
266
+ inspected_registrations = tuple(
267
+ (registration, inspect_command(registration, windows=windows))
268
+ for registration in registrations
269
+ )
270
+ findings = list(source_findings)
271
+ for registration, inspected in inspected_registrations:
272
+ findings.extend(registration.configuration_findings)
273
+ findings.extend(
274
+ analyze_registration(
275
+ spec,
276
+ (registration, inspected),
277
+ path_value=path_value,
278
+ path_ext=path_ext,
279
+ windows=windows,
280
+ )
281
+ )
282
+ findings.extend(loader.analyze_registration(registration, inspected, windows=windows))
283
+ findings.extend(analyze_registration_pairs(inspected_registrations, windows=windows))
284
+ return _report(
285
+ DoctorReport(
286
+ host,
287
+ None,
288
+ _installed_version(),
289
+ inspect_platform(),
290
+ sources,
291
+ registrations,
292
+ tuple(findings),
293
+ loader.inspection_limitations(),
294
+ ),
295
+ path_value=path_value,
296
+ path_ext=path_ext,
297
+ )
298
+
299
+
300
+ def run_doctor( # noqa: PLR0913
301
+ host: str,
302
+ *,
303
+ home: Path,
304
+ cwd: Path,
305
+ system_root: Path,
306
+ path_value: str | None,
307
+ path_ext: str | None,
308
+ stream: DoctorOutput,
309
+ ) -> int:
310
+ """Inspect, render, and return the doctor status.
311
+
312
+ Args:
313
+ host: Entry-point name of the host to inspect; an unloadable host is
314
+ reported as a `host_unavailable` finding.
315
+ home: Injected home directory used for user configuration.
316
+ cwd: Injected working directory used for project configuration.
317
+ system_root: Injected filesystem root used for system configuration.
318
+ path_value: Injected executable search path, if set.
319
+ path_ext: Injected Windows executable suffix list, if set.
320
+ stream: Text output destination for the report.
321
+
322
+ Returns:
323
+ Zero when findings are informational, otherwise one.
324
+
325
+ Raises:
326
+ OSError: If rendering the report to `stream` fails.
327
+ """
328
+ report = _inspect_host(
329
+ host,
330
+ home=home,
331
+ cwd=cwd,
332
+ system_root=system_root,
333
+ path_value=path_value,
334
+ path_ext=path_ext,
335
+ )
336
+ _render_report(report, stream)
337
+ return int(
338
+ any(
339
+ finding.severity in {FindingSeverity.ERROR, FindingSeverity.WARNING}
340
+ for finding in report.findings
341
+ )
342
+ )