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,478 @@
1
+ """Runner-neutral orchestration for one workflow model invocation.
2
+
3
+ The adapters own process invocation and protocol parsing. This module owns
4
+ profile resolution, the common request shape, call accounting, lifecycle
5
+ cleanup, and the bounded user-facing error boundary.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import pathlib
12
+ import stat
13
+ from dataclasses import dataclass
14
+
15
+ from ..config import resolve_runner_profile
16
+ from ..paths import is_path_within, path_has_symlink
17
+ from ..types import HookError, RunnerProfile, RuntimeContext, StepConfig
18
+ from .runners import (
19
+ Runner,
20
+ RunnerArtifact,
21
+ RunnerRequest,
22
+ RunnerResult,
23
+ SessionMetadata,
24
+ bounded_diagnostic,
25
+ finalize_runner,
26
+ get_runner,
27
+ redact_diagnostic,
28
+ require_final_text,
29
+ require_zero_exit,
30
+ )
31
+ from .runners.contracts import RunnerProtocolError, request_sensitive_diagnostics
32
+
33
+
34
+ RUNNER_INPUT_MAX_BYTES = 16 * 1024 * 1024
35
+ RUNNER_INPUT_READ_CHUNK_BYTES = 1024 * 1024
36
+
37
+
38
+ @dataclass
39
+ class _RunnerInvocation:
40
+ request: RunnerRequest
41
+ runner: Runner
42
+ result: RunnerResult
43
+ call_number: int
44
+
45
+
46
+ def _selected_runner_name(context: RuntimeContext, step: StepConfig) -> str:
47
+ return step.runner or context.config.llm.runner
48
+
49
+
50
+ def _safe_input_artifacts(
51
+ context: RuntimeContext,
52
+ step: StepConfig,
53
+ input_paths: list[pathlib.Path],
54
+ ) -> tuple[RunnerArtifact, ...]:
55
+ """Build the ordered logical artifact list shared by every adapter.
56
+
57
+ ``validate_hook_owned_artifacts`` is the hook-owned artifact boundary used
58
+ before adapter dispatch. The logical names are the
59
+ configured input references, rather than filesystem basenames, so command,
60
+ Codex, Claude, and OpenCode receive the same ordered context.
61
+ """
62
+
63
+ from .runners.opencode_support import validate_hook_owned_artifacts
64
+
65
+ validated = validate_hook_owned_artifacts(context, input_paths)
66
+ if len(validated) != len(step.inputs):
67
+ raise HookError(
68
+ f"Runner input count does not match configured inputs for step `{step.id}`"
69
+ )
70
+ run_root = context.run_dir.resolve(strict=True)
71
+ artifacts: list[RunnerArtifact] = []
72
+ total_bytes = 0
73
+ for name, path in zip(step.inputs, validated, strict=True):
74
+ lexical_path = pathlib.Path(os.path.abspath(path))
75
+ # The first validation happens before this loop. Recheck the lexical
76
+ # parents immediately before opening each file; this is a same-user
77
+ # race defense, not an OS sandbox. O_NOFOLLOW and descriptor checks
78
+ # below protect the final open even if the leaf changes concurrently.
79
+ if (
80
+ not is_path_within(lexical_path, run_root)
81
+ or path_has_symlink(run_root, lexical_path)
82
+ ):
83
+ raise HookError(f"Runner artifact must not traverse a symlink: {name}")
84
+ try:
85
+ resolved_path = lexical_path.resolve(strict=True)
86
+ except (OSError, RuntimeError) as exc:
87
+ raise HookError(f"Unable to safely resolve runner artifact: {name}") from exc
88
+ if (
89
+ resolved_path != lexical_path
90
+ or not is_path_within(resolved_path, run_root)
91
+ or not resolved_path.is_file()
92
+ ):
93
+ raise HookError(f"Runner artifact must be a regular hook-owned file: {name}")
94
+
95
+ descriptor: int | None = None
96
+ try:
97
+ flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
98
+ descriptor = os.open(lexical_path, flags)
99
+ metadata = os.fstat(descriptor)
100
+ if not os.path.isfile(lexical_path) or not stat.S_ISREG(metadata.st_mode):
101
+ raise HookError(f"Runner artifact must be a regular hook-owned file: {name}")
102
+ remaining = RUNNER_INPUT_MAX_BYTES - total_bytes
103
+ if metadata.st_size > remaining:
104
+ raise HookError(
105
+ f"Runner input artifacts exceed the {RUNNER_INPUT_MAX_BYTES}-byte budget"
106
+ )
107
+ content_bytes = bytearray()
108
+ while True:
109
+ read_limit = min(
110
+ RUNNER_INPUT_READ_CHUNK_BYTES,
111
+ remaining - len(content_bytes) + 1,
112
+ )
113
+ chunk = os.read(descriptor, max(1, read_limit))
114
+ if not chunk:
115
+ break
116
+ content_bytes.extend(chunk)
117
+ if len(content_bytes) > remaining:
118
+ raise HookError(
119
+ f"Runner input artifacts exceed the {RUNNER_INPUT_MAX_BYTES}-byte budget"
120
+ )
121
+ total_bytes += len(content_bytes)
122
+ content = bytes(content_bytes).decode("utf-8")
123
+ except (OSError, UnicodeError) as exc:
124
+ raise HookError(f"Unable to read hook-owned runner artifact: {name}") from exc
125
+ finally:
126
+ if descriptor is not None:
127
+ os.close(descriptor)
128
+ artifacts.append(RunnerArtifact(name=name, content=content, path=path))
129
+ return tuple(artifacts)
130
+
131
+
132
+ def _build_request(
133
+ context: RuntimeContext,
134
+ step: StepConfig,
135
+ prompt: str,
136
+ input_paths: list[pathlib.Path],
137
+ stage_name: str,
138
+ working_directory: pathlib.Path,
139
+ *,
140
+ session_id: str | None,
141
+ resume_session: bool,
142
+ ) -> tuple[RunnerProfile, RunnerRequest]:
143
+ profile = resolve_runner_profile(context.config, step)
144
+ cwd = pathlib.Path(working_directory).resolve(strict=True)
145
+ if not cwd.is_dir():
146
+ raise HookError(f"Runner working directory is not a directory: {working_directory}")
147
+ request = RunnerRequest(
148
+ profile_id=profile.name,
149
+ runner_type=profile.type,
150
+ stage=stage_name,
151
+ purpose=f"{step.type}:{step.id}",
152
+ mode=step.type, # type: ignore[arg-type]
153
+ instruction=prompt,
154
+ artifacts=_safe_input_artifacts(context, step, input_paths),
155
+ cwd=cwd,
156
+ timeout_seconds=context.config.llm.timeout_seconds,
157
+ model=profile.model,
158
+ variant=profile.variant,
159
+ project_access=profile.project_access, # type: ignore[arg-type]
160
+ allow_paths=step.allow_paths,
161
+ command=profile.command,
162
+ prompt_transport=profile.prompt_transport, # type: ignore[arg-type]
163
+ session_id=session_id,
164
+ resume_session=resume_session,
165
+ integration_context=context,
166
+ )
167
+ return profile, request
168
+
169
+
170
+ def _call_logger(
171
+ context: RuntimeContext,
172
+ request: RunnerRequest,
173
+ model: str | None,
174
+ *,
175
+ attempt: int | None,
176
+ total_attempts: int | None,
177
+ ) -> int:
178
+ return context.logger.llm_call(
179
+ request.stage,
180
+ request.purpose,
181
+ model or "",
182
+ attempt,
183
+ total_attempts,
184
+ runner_profile=request.profile_id,
185
+ runner_type=request.runner_type,
186
+ )
187
+
188
+
189
+ def _session_metadata(runner: Runner, session_id: str) -> SessionMetadata:
190
+ supports_resume = bool(
191
+ getattr(getattr(runner, "capabilities", None), "supports_resume", False)
192
+ )
193
+ return SessionMetadata(
194
+ session_id=session_id,
195
+ state="persisted" if supports_resume else "ephemeral",
196
+ resumable=supports_resume,
197
+ )
198
+
199
+
200
+ def _failure_result(
201
+ runner: Runner,
202
+ error: BaseException,
203
+ fallback_session_id: str | None = None,
204
+ ) -> RunnerResult:
205
+ session_id = getattr(error, "session_id", None)
206
+ if not isinstance(session_id, str) or not session_id.strip():
207
+ session_id = fallback_session_id
208
+ session = _session_metadata(runner, session_id.strip()) if session_id else None
209
+ return RunnerResult(final_text="", returncode=1, stdout="", stderr="", session=session)
210
+
211
+
212
+ def _preserve_failure_session(
213
+ runner: Runner,
214
+ result: RunnerResult,
215
+ fallback_session_id: str | None,
216
+ ) -> RunnerResult:
217
+ if (result.session is not None and result.session.session_id) or not fallback_session_id:
218
+ return result
219
+ return RunnerResult(
220
+ final_text=result.final_text,
221
+ returncode=result.returncode,
222
+ stdout=result.stdout,
223
+ stderr=result.stderr,
224
+ session=_session_metadata(runner, fallback_session_id),
225
+ transcript=result.transcript,
226
+ )
227
+
228
+
229
+ def _completion(
230
+ context: RuntimeContext,
231
+ invocation: _RunnerInvocation,
232
+ *,
233
+ failed: bool,
234
+ ) -> None:
235
+ session = invocation.result.session
236
+ context.logger.llm_complete(
237
+ invocation.call_number,
238
+ invocation.request.stage,
239
+ invocation.request.profile_id,
240
+ invocation.request.runner_type,
241
+ session_id=session.session_id if session else None,
242
+ session_state=session.state if session else None,
243
+ resumable=session.resumable if session else False,
244
+ transcript=session.transcript if session else None,
245
+ # No adapter currently supplies a safe, provider-correct resume
246
+ # command. In particular, ordinary ``opencode -s`` is invalid
247
+ # for the isolated scratch state used by hook runs.
248
+ resume_command=None,
249
+ failed=failed,
250
+ )
251
+
252
+
253
+ def _print_normalized_output(context: RuntimeContext, invocation: _RunnerInvocation) -> None:
254
+ if not context.config.logging.print_llm_output or not invocation.result.final_text:
255
+ return
256
+ # This is an explicit opt-in. Only normalized final text is printed and
257
+ # request/environment values are redacted; raw child streams stay private.
258
+ print(
259
+ redact_diagnostic(
260
+ invocation.result.final_text,
261
+ secrets=_request_sensitive_values(invocation.request),
262
+ )
263
+ )
264
+
265
+
266
+ def _request_sensitive_values(request: RunnerRequest) -> tuple[str, ...]:
267
+ environment_values = tuple(
268
+ value
269
+ for name, value in os.environ.items()
270
+ if value
271
+ and any(
272
+ marker in name.upper()
273
+ for marker in ("API_KEY", "TOKEN", "SECRET", "PASSWORD", "AUTH", "CREDENTIAL")
274
+ )
275
+ )
276
+ return (
277
+ request.instruction,
278
+ request.prompt_packet().render(),
279
+ *(artifact.content for artifact in request.artifacts),
280
+ *environment_values,
281
+ )
282
+
283
+
284
+ def _named_error(
285
+ profile_name: str,
286
+ runner_type: str,
287
+ stage_name: str,
288
+ error: BaseException,
289
+ *,
290
+ request: RunnerRequest | None = None,
291
+ ) -> HookError:
292
+ if request is None:
293
+ details = bounded_diagnostic(str(error), max_chars=1_200)
294
+ else:
295
+ process_result = getattr(error, "_process_result", None)
296
+ stdout = getattr(process_result, "stdout", "")
297
+ stderr = getattr(process_result, "stderr", "")
298
+ if not isinstance(stdout, str):
299
+ stdout = ""
300
+ if not isinstance(stderr, str):
301
+ stderr = ""
302
+ reason = request_sensitive_diagnostics(
303
+ request,
304
+ str(error),
305
+ max_chars=1_200,
306
+ env=os.environ,
307
+ )
308
+ streams = request_sensitive_diagnostics(
309
+ request,
310
+ stdout,
311
+ stderr,
312
+ max_chars=1_200,
313
+ env=os.environ,
314
+ )
315
+ details = bounded_diagnostic(
316
+ "\n".join(part for part in (reason, streams) if part),
317
+ max_chars=1_200,
318
+ )
319
+ message = (
320
+ f"Runner profile `{profile_name}` ({runner_type}) failed at stage `{stage_name}`"
321
+ )
322
+ if details:
323
+ message += f": {details}"
324
+ return HookError(message)
325
+
326
+
327
+ def _invoke_runner(
328
+ context: RuntimeContext,
329
+ step: StepConfig,
330
+ prompt: str,
331
+ input_paths: list[pathlib.Path],
332
+ stage_name: str,
333
+ *,
334
+ working_directory: pathlib.Path,
335
+ session_id: str | None = None,
336
+ resume_session: bool = False,
337
+ attempt: int | None = None,
338
+ total_attempts: int | None = None,
339
+ prior_invocation: _RunnerInvocation | None = None,
340
+ ) -> _RunnerInvocation:
341
+ selected_name = _selected_runner_name(context, step)
342
+ profile_name = selected_name
343
+ runner_type = "unknown"
344
+ request: RunnerRequest | None = None
345
+ try:
346
+ profile, request = _build_request(
347
+ context,
348
+ step,
349
+ prompt,
350
+ input_paths,
351
+ stage_name,
352
+ working_directory,
353
+ session_id=session_id,
354
+ resume_session=resume_session,
355
+ )
356
+ profile_name = profile.name
357
+ runner_type = profile.type
358
+ # Adapter construction (including optional CLI capability checks) is
359
+ # deliberately before call accounting. Counts represent invocations,
360
+ # never capability probes.
361
+ runner = get_runner(profile.type)
362
+ call_number = _call_logger(
363
+ context,
364
+ request,
365
+ profile.model,
366
+ attempt=attempt,
367
+ total_attempts=total_attempts,
368
+ )
369
+ except Exception as exc: # noqa: BLE001
370
+ if prior_invocation is not None:
371
+ try:
372
+ _finalize_invocation(context, prior_invocation, failed=True)
373
+ except Exception as finalize_error: # noqa: BLE001
374
+ context.logger.warn(
375
+ "llm.finalize_failed",
376
+ "Runner lifecycle finalization failed while cleaning a retained retry session.",
377
+ stage_name=stage_name,
378
+ runner_profile=prior_invocation.request.profile_id,
379
+ runner_type=prior_invocation.request.runner_type,
380
+ reason=type(finalize_error).__name__,
381
+ )
382
+ raise _named_error(
383
+ profile_name,
384
+ runner_type,
385
+ stage_name,
386
+ exc,
387
+ request=request,
388
+ ) from exc
389
+
390
+ result: RunnerResult | None = None
391
+ try:
392
+ result = runner.run(request)
393
+ if not isinstance(result, RunnerResult):
394
+ raise RunnerProtocolError("runner returned an invalid result")
395
+ require_zero_exit(request, result, env=os.environ)
396
+ require_final_text(result.final_text, mode=request.mode)
397
+ except Exception as exc: # noqa: BLE001
398
+ # A runner may have learned a session ID before reporting a process or
399
+ # protocol failure. Give the optional lifecycle a chance to clean it
400
+ # up, even though no successful RunnerResult was returned.
401
+ if isinstance(result, RunnerResult):
402
+ result = _preserve_failure_session(runner, result, request.session_id)
403
+ else:
404
+ result = _failure_result(runner, exc, request.session_id)
405
+ invocation = _RunnerInvocation(request, runner, result, call_number)
406
+ try:
407
+ invocation.result = finalize_runner(runner, request, result)
408
+ except Exception as finalize_error: # noqa: BLE001
409
+ context.logger.warn(
410
+ "llm.finalize_failed",
411
+ "Runner lifecycle finalization failed after a runner error.",
412
+ stage_name=stage_name,
413
+ runner_profile=profile_name,
414
+ runner_type=runner_type,
415
+ reason=type(finalize_error).__name__,
416
+ )
417
+ _completion(context, invocation, failed=True)
418
+ raise _named_error(
419
+ profile_name,
420
+ runner_type,
421
+ stage_name,
422
+ exc,
423
+ request=request,
424
+ ) from exc
425
+
426
+ return _RunnerInvocation(request, runner, result, call_number)
427
+
428
+
429
+ def _finalize_invocation(
430
+ context: RuntimeContext,
431
+ invocation: _RunnerInvocation,
432
+ *,
433
+ failed: bool,
434
+ ) -> RunnerResult:
435
+ try:
436
+ invocation.result = finalize_runner(
437
+ invocation.runner,
438
+ invocation.request,
439
+ invocation.result,
440
+ )
441
+ except Exception as exc: # noqa: BLE001
442
+ _completion(context, invocation, failed=True)
443
+ raise _named_error(
444
+ invocation.request.profile_id,
445
+ invocation.request.runner_type,
446
+ invocation.request.stage,
447
+ exc,
448
+ request=invocation.request,
449
+ ) from exc
450
+ _completion(context, invocation, failed=failed)
451
+ if not failed:
452
+ _print_normalized_output(context, invocation)
453
+ return invocation.result
454
+
455
+
456
+ def run_runner_once(
457
+ context: RuntimeContext,
458
+ step: StepConfig,
459
+ prompt: str,
460
+ input_paths: list[pathlib.Path],
461
+ stage_name: str,
462
+ *,
463
+ working_directory: pathlib.Path,
464
+ ) -> RunnerResult:
465
+ """Resolve, invoke, validate, finalize, and report one runner call."""
466
+
467
+ invocation = _invoke_runner(
468
+ context,
469
+ step,
470
+ prompt,
471
+ input_paths,
472
+ stage_name,
473
+ working_directory=working_directory,
474
+ )
475
+ return _finalize_invocation(context, invocation, failed=False)
476
+
477
+
478
+ __all__ = ["run_runner_once"]
@@ -0,0 +1,78 @@
1
+ """Internal runner boundary used by future workflow integrations.
2
+
3
+ Adapters are deliberately not imported here. ST-2 supplies the four lazy
4
+ adapter modules while ST-3 supplies workflow dispatch. Importing this package
5
+ is therefore safe in installations that do not have any selected CLI.
6
+ """
7
+
8
+ from .contracts import (
9
+ MAX_DIAGNOSTIC_CHARS,
10
+ Runner,
11
+ RunnerAdapterUnavailableError,
12
+ RunnerArtifact,
13
+ RunnerCapabilities,
14
+ RunnerContractError,
15
+ RunnerError,
16
+ RunnerExecutableNotFoundError,
17
+ RunnerMissingOutputError,
18
+ RunnerNonzeroExitError,
19
+ RunnerProtocolError,
20
+ RunnerRequest,
21
+ RunnerResult,
22
+ RunnerSignalError,
23
+ RunnerTimeoutError,
24
+ SessionMetadata,
25
+ PromptPacket,
26
+ build_prompt_packet,
27
+ bounded_diagnostic,
28
+ bounded_redacted_diagnostics,
29
+ finalize_runner,
30
+ redact_diagnostic,
31
+ require_final_text,
32
+ require_zero_exit,
33
+ strip_terminal_controls,
34
+ )
35
+ from .process import DEFAULT_MAX_OUTPUT_BYTES, ProcessResult, run_process
36
+ from .registry import (
37
+ DEFAULT_RUNNER_REGISTRY,
38
+ KNOWN_RUNNER_TYPES,
39
+ LazyRunnerSpec,
40
+ RunnerRegistry,
41
+ get_runner,
42
+ )
43
+
44
+ __all__ = [
45
+ "DEFAULT_MAX_OUTPUT_BYTES",
46
+ "DEFAULT_RUNNER_REGISTRY",
47
+ "KNOWN_RUNNER_TYPES",
48
+ "MAX_DIAGNOSTIC_CHARS",
49
+ "LazyRunnerSpec",
50
+ "ProcessResult",
51
+ "PromptPacket",
52
+ "Runner",
53
+ "RunnerAdapterUnavailableError",
54
+ "RunnerArtifact",
55
+ "RunnerCapabilities",
56
+ "RunnerContractError",
57
+ "RunnerError",
58
+ "RunnerExecutableNotFoundError",
59
+ "RunnerMissingOutputError",
60
+ "RunnerNonzeroExitError",
61
+ "RunnerProtocolError",
62
+ "RunnerRegistry",
63
+ "RunnerRequest",
64
+ "RunnerResult",
65
+ "RunnerSignalError",
66
+ "RunnerTimeoutError",
67
+ "SessionMetadata",
68
+ "bounded_diagnostic",
69
+ "bounded_redacted_diagnostics",
70
+ "build_prompt_packet",
71
+ "finalize_runner",
72
+ "get_runner",
73
+ "redact_diagnostic",
74
+ "require_final_text",
75
+ "require_zero_exit",
76
+ "run_process",
77
+ "strip_terminal_controls",
78
+ ]