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
@@ -1,11 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import math
3
4
  import os
4
5
  import pathlib
6
+ import re
5
7
  import stat
8
+ from collections.abc import Mapping
6
9
  from typing import Any
7
10
 
8
- from .executors.exec import env_bool, resolve_git_common_dir, resolve_git_dir
11
+ from .git_utils import env_bool, resolve_git_common_dir, resolve_git_dir
9
12
  from .paths import (
10
13
  is_path_within,
11
14
  normalized_component,
@@ -15,14 +18,26 @@ from .paths import (
15
18
  validate_path_component,
16
19
  )
17
20
  from .prompts_builtin import BUILTIN_PROMPTS
18
- from .types import GeneralConfig, HookConfig, HookError, LlmConfig, LoggingConfig, ModuleConfig, StepConfig, SUPPORTED_STEP_TYPES, WorkflowConfig
21
+ from .types import (
22
+ DEFAULT_STEP_COMMAND_TIMEOUT_SECONDS,
23
+ SUPPORTED_STEP_TYPES,
24
+ GeneralConfig,
25
+ HookConfig,
26
+ HookError,
27
+ LlmConfig,
28
+ LoggingConfig,
29
+ ModuleConfig,
30
+ RunnerProfile,
31
+ StepConfig,
32
+ WorkflowConfig,
33
+ )
19
34
 
20
35
  try:
21
36
  import tomllib
22
37
  except ModuleNotFoundError: # pragma: no cover
23
38
  import tomli as tomllib
24
39
 
25
- ALLOWED_TOP_LEVEL_KEYS = {"general", "llm", "logging", "workflow", "modules"}
40
+ ALLOWED_TOP_LEVEL_KEYS = {"general", "llm", "logging", "workflow", "modules", "runners"}
26
41
  STORAGE_NAMESPACE_PARTS = (".git", "ai-push-hooks")
27
42
  GENERAL_KEYS = {
28
43
  "enabled",
@@ -66,8 +81,33 @@ STEP_KEYS = {
66
81
  "allow_paths",
67
82
  "executor",
68
83
  "assertion",
84
+ "python",
85
+ "options",
86
+ "command",
87
+ "stdin",
88
+ "timeout_seconds",
69
89
  "when_env",
90
+ "runner",
70
91
  }
92
+ RUNNER_TYPES = frozenset({"opencode", "codex", "claude", "command"})
93
+ PROJECT_ACCESS_VALUES = frozenset({"artifacts", "project"})
94
+ PROMPT_TRANSPORT_VALUES = frozenset({"stdin", "argv"})
95
+ RUNNER_KEYS = {
96
+ "type",
97
+ "model",
98
+ "variant",
99
+ "project_access",
100
+ "command",
101
+ "prompt_transport",
102
+ }
103
+ RUNNER_PLACEHOLDERS = frozenset({"{prompt}", "{model}", "{cwd}", "{stage}"})
104
+ PYTHON_CALLABLE_PATTERN = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\Z")
105
+ COMMAND_PLACEHOLDER_PATTERN = re.compile(r"\{([A-Za-z0-9_./:-]+)\}\Z")
106
+ EMBEDDED_COMMAND_PLACEHOLDER_PATTERN = re.compile(
107
+ r"\{(?:repo|python|input:[A-Za-z0-9_./:-]+)\}"
108
+ )
109
+ COMMAND_PLACEHOLDER_NAMES = frozenset({"repo", "python"})
110
+ RUNNER_PLACEHOLDER_PATTERN = re.compile(r"\{[^{}]*\}")
71
111
 
72
112
 
73
113
  def _require_table(value: Any, label: str) -> dict[str, Any]:
@@ -106,6 +146,122 @@ def _validate_string_list(table: dict[str, Any], key: str, label: str) -> None:
106
146
  raise HookError(f"{label}.{key} must be an array of strings")
107
147
 
108
148
 
149
+ def _validate_non_empty_string(table: dict[str, Any], key: str, label: str) -> None:
150
+ _validate_string(table, key, label)
151
+ if key in table and not table[key].strip():
152
+ raise HookError(f"{label}.{key} must be a non-empty string")
153
+
154
+
155
+ def _validate_no_control_chars(value: str, label: str) -> None:
156
+ if any(ord(character) < 32 or ord(character) == 127 for character in value):
157
+ raise HookError(f"{label} must not contain NUL or control characters")
158
+
159
+
160
+ def _validate_model_override(value: str | None) -> None:
161
+ if value is not None and not value.strip():
162
+ raise HookError("AI_PUSH_HOOKS_MODEL must be a non-empty model identifier")
163
+ if value is not None:
164
+ _validate_no_control_chars(value, "AI_PUSH_HOOKS_MODEL")
165
+
166
+
167
+ def _validate_variant_override(value: str | None) -> None:
168
+ if value is not None:
169
+ _validate_no_control_chars(value, "AI_PUSH_HOOKS_VARIANT")
170
+
171
+
172
+ def _validate_runner_command_placeholders(
173
+ command: list[str] | tuple[str, ...],
174
+ label: str,
175
+ transport: str,
176
+ model: Any,
177
+ effective_model: str | None = None,
178
+ ) -> None:
179
+ prompt_count = 0
180
+ for index, argument in enumerate(command, start=1):
181
+ argument_label = f"{label}.command[{index}]"
182
+ _validate_no_control_chars(argument, argument_label)
183
+ for placeholder in RUNNER_PLACEHOLDER_PATTERN.findall(argument):
184
+ if placeholder not in RUNNER_PLACEHOLDERS:
185
+ raise HookError(f"Unknown placeholder {placeholder!r} in {argument_label}")
186
+ if ("{" in argument or "}" in argument) and argument not in RUNNER_PLACEHOLDERS:
187
+ raise HookError(
188
+ f"Placeholders in {argument_label} must be whole argv elements"
189
+ )
190
+ if argument == "{prompt}":
191
+ prompt_count += 1
192
+ if transport == "stdin" and prompt_count:
193
+ raise HookError(f"{label}.command must not contain {{prompt}} with stdin transport")
194
+ if transport == "argv" and prompt_count != 1:
195
+ raise HookError(
196
+ f"{label}.command must contain exactly one {{prompt}} with argv transport"
197
+ )
198
+ if "{model}" in command and not (model or effective_model):
199
+ raise HookError(f"{label}.command uses {{model}} but {label}.model is not configured")
200
+
201
+
202
+ def _validate_runner_profiles(
203
+ raw: dict[str, Any], effective_model: str | None = None
204
+ ) -> None:
205
+ runners = _require_table(raw.get("runners", {}), "runners")
206
+ for name, profile_value in runners.items():
207
+ if not isinstance(name, str) or not name.strip():
208
+ raise HookError("runners profile names must be non-empty strings")
209
+ _validate_no_control_chars(name, f"runners profile name `{name}`")
210
+ label = f"runners.{name}"
211
+ profile = _require_table(profile_value, label)
212
+ _validate_unknown_keys(profile, RUNNER_KEYS, label)
213
+ if "type" not in profile:
214
+ raise HookError(f"{label}.type is required")
215
+ _validate_non_empty_string(profile, "type", label)
216
+ runner_type = profile["type"].strip()
217
+ _validate_no_control_chars(profile["type"], f"{label}.type")
218
+ if runner_type not in RUNNER_TYPES:
219
+ raise HookError(f"{label}.type must be one of: {', '.join(sorted(RUNNER_TYPES))}")
220
+ _validate_string(profile, "model", label)
221
+ if "model" in profile and not profile["model"].strip():
222
+ raise HookError(f"{label}.model must be a non-empty string when provided")
223
+ if "model" in profile:
224
+ _validate_no_control_chars(profile["model"], f"{label}.model")
225
+ _validate_string(profile, "variant", label)
226
+ if "variant" in profile:
227
+ _validate_no_control_chars(profile["variant"], f"{label}.variant")
228
+ _validate_string(profile, "project_access", label)
229
+ _validate_string(profile, "prompt_transport", label)
230
+ if "project_access" in profile and profile["project_access"] not in PROJECT_ACCESS_VALUES:
231
+ raise HookError(f"{label}.project_access must be one of: artifacts, project")
232
+
233
+ type_specific_keys = {
234
+ "variant": runner_type == "opencode",
235
+ "command": runner_type == "command",
236
+ "prompt_transport": runner_type == "command",
237
+ }
238
+ for key, applicable in type_specific_keys.items():
239
+ if key in profile and not applicable:
240
+ raise HookError(f"{label}.{key} is only valid for runner type {('opencode' if key == 'variant' else 'command')}")
241
+
242
+ if runner_type != "command":
243
+ continue
244
+ if "command" not in profile:
245
+ raise HookError(f"{label}.command is required for runner type command")
246
+ _validate_string_list(profile, "command", label)
247
+ command = profile["command"]
248
+ if not command:
249
+ raise HookError(f"{label}.command must be a non-empty array")
250
+ for index, argument in enumerate(command, start=1):
251
+ if not argument.strip():
252
+ raise HookError(f"{label}.command[{index}] must be a non-empty string")
253
+ transport = profile.get("prompt_transport", "stdin")
254
+ if transport not in PROMPT_TRANSPORT_VALUES:
255
+ raise HookError(f"{label}.prompt_transport must be one of: argv, stdin")
256
+ _validate_runner_command_placeholders(
257
+ command,
258
+ label,
259
+ transport,
260
+ profile.get("model"),
261
+ effective_model,
262
+ )
263
+
264
+
109
265
  def _validate_integer(
110
266
  table: dict[str, Any], key: str, label: str, *, minimum: int | None = None
111
267
  ) -> None:
@@ -118,6 +274,198 @@ def _validate_integer(
118
274
  raise HookError(f"{label}.{key} must be at least {minimum}")
119
275
 
120
276
 
277
+ def _validate_json_options(value: Any, label: str, *, path: str = "") -> None:
278
+ """Validate TOML options as finite, null-free JSON data."""
279
+
280
+ location = f"{label}{path}"
281
+ if value is None:
282
+ raise HookError(f"{location} must not be null")
283
+ if isinstance(value, (bool, str, int)):
284
+ return
285
+ if isinstance(value, float):
286
+ if not math.isfinite(value):
287
+ raise HookError(f"{location} must be a finite number")
288
+ return
289
+ if isinstance(value, list):
290
+ for index, item in enumerate(value, start=1):
291
+ _validate_json_options(item, label, path=f"{path}[{index}]")
292
+ return
293
+ if isinstance(value, dict):
294
+ for key, item in value.items():
295
+ if not isinstance(key, str):
296
+ raise HookError(f"{location} must use string keys")
297
+ _validate_json_options(item, label, path=f"{path}.{key}")
298
+ return
299
+ raise HookError(
300
+ f"{location} must contain only JSON-compatible null-free values"
301
+ )
302
+
303
+
304
+ def _validate_python_reference(
305
+ value: str,
306
+ label: str,
307
+ repo_root: pathlib.Path | None = None,
308
+ ) -> str:
309
+ """Validate a repository-local callback reference without importing it."""
310
+
311
+ if value.count(":") != 1:
312
+ raise HookError(
313
+ f"{label} must be a repository-relative .py path followed by :callable"
314
+ )
315
+ path_value, callable_name = value.split(":", 1)
316
+ if not callable_name or not PYTHON_CALLABLE_PATTERN.fullmatch(callable_name):
317
+ raise HookError(f"{label} callable must be one top-level identifier")
318
+ try:
319
+ parts = relative_path_parts(path_value, f"{label} path")
320
+ except HookError as exc:
321
+ raise HookError(str(exc)) from exc
322
+ if not parts[-1].endswith(".py"):
323
+ raise HookError(f"{label} path must name a .py file")
324
+ normalized = "/".join(parts) + ":" + callable_name
325
+
326
+ if repo_root is None:
327
+ return normalized
328
+
329
+ root = pathlib.Path(repo_root).resolve(strict=False)
330
+ lexical_path = root.joinpath(*parts)
331
+ if path_has_symlink(root, lexical_path):
332
+ raise HookError(f"{label} path must not traverse a symlink or reparse point")
333
+ try:
334
+ callback_path = resolve_contained_path(
335
+ root, "/".join(parts), f"{label} path"
336
+ )
337
+ except HookError as exc:
338
+ raise HookError(str(exc)) from exc
339
+ try:
340
+ metadata = callback_path.lstat()
341
+ except FileNotFoundError as exc:
342
+ raise HookError(f"{label} path must reference an existing regular file") from exc
343
+ reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
344
+ if stat.S_ISLNK(metadata.st_mode) or bool(
345
+ getattr(metadata, "st_file_attributes", 0) & reparse_flag
346
+ ):
347
+ raise HookError(f"{label} path must not be a symlink or reparse point")
348
+ if not stat.S_ISREG(metadata.st_mode):
349
+ raise HookError(f"{label} path must reference an ordinary regular file")
350
+
351
+ flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
352
+ try:
353
+ descriptor = os.open(callback_path, flags)
354
+ except OSError as exc:
355
+ raise HookError(f"{label} path could not be opened safely") from exc
356
+ try:
357
+ descriptor_metadata = os.fstat(descriptor)
358
+ if not stat.S_ISREG(descriptor_metadata.st_mode):
359
+ raise HookError(f"{label} path must reference an ordinary regular file")
360
+ finally:
361
+ os.close(descriptor)
362
+ return normalized
363
+
364
+
365
+ def _validate_step_extensions(
366
+ step: dict[str, Any],
367
+ label: str,
368
+ *,
369
+ repo_root: pathlib.Path | None = None,
370
+ ) -> None:
371
+ """Validate the implementation seams shared by config type checking/building."""
372
+
373
+ step_type = step.get("type")
374
+ if not isinstance(step_type, str):
375
+ return
376
+
377
+ implementation_keys = {
378
+ "collect": ("collector", "python"),
379
+ "exec": ("executor", "python", "command"),
380
+ "assert": ("assertion", "python", "command"),
381
+ }
382
+ implementations = implementation_keys.get(step_type)
383
+ if implementations is not None:
384
+ for key in implementations:
385
+ if key in {"collector", "executor", "assertion", "python"}:
386
+ value = step.get(key)
387
+ if isinstance(value, str) and not value.strip():
388
+ raise HookError(f"{label}.{key} must be a non-empty string")
389
+ if "command" in step and not step["command"]:
390
+ raise HookError(f"{label}.command must be a non-empty array")
391
+ selected = [key for key in implementations if step.get(key) not in (None, "")]
392
+ if len(selected) != 1:
393
+ choices = ", ".join(implementations)
394
+ raise HookError(
395
+ f"{label} requires exactly one implementation field: {choices}"
396
+ )
397
+
398
+ applicable = {
399
+ "collector": {"collect"},
400
+ "executor": {"exec"},
401
+ "assertion": {"assert"},
402
+ "python": {"collect", "exec", "assert"},
403
+ "command": {"exec", "assert"},
404
+ "stdin": {"exec", "assert"},
405
+ "timeout_seconds": {"exec", "assert"},
406
+ "options": {"collect", "exec", "assert"},
407
+ }
408
+ for key, allowed_types in applicable.items():
409
+ if key in step and step_type not in allowed_types:
410
+ raise HookError(f"{label}.{key} is not valid for {step_type} steps")
411
+
412
+ has_python = step.get("python") not in (None, "")
413
+ has_command = bool(step.get("command"))
414
+ if "options" in step:
415
+ if not has_python:
416
+ raise HookError(f"{label}.options requires {label}.python")
417
+ _validate_json_options(step["options"], f"{label}.options")
418
+ if "stdin" in step and not has_command:
419
+ raise HookError(f"{label}.stdin is only valid with {label}.command")
420
+ if "timeout_seconds" in step and not has_command:
421
+ raise HookError(f"{label}.timeout_seconds is only valid with {label}.command")
422
+
423
+ if has_python:
424
+ inputs = step.get("inputs", [])
425
+ if len(inputs) != len(set(inputs)):
426
+ raise HookError(
427
+ f"{label}.inputs must not contain duplicate references for Python steps"
428
+ )
429
+ _validate_python_reference(step["python"], f"{label}.python", repo_root)
430
+
431
+ if has_command:
432
+ command = step["command"]
433
+ declared_inputs = set(step.get("inputs", []))
434
+ for index, argument in enumerate(command, start=1):
435
+ match = COMMAND_PLACEHOLDER_PATTERN.fullmatch(argument)
436
+ if match is not None:
437
+ token = match.group(1)
438
+ if token in COMMAND_PLACEHOLDER_NAMES:
439
+ continue
440
+ if token.startswith("input:"):
441
+ logical_ref = token.removeprefix("input:")
442
+ if logical_ref not in declared_inputs:
443
+ raise HookError(
444
+ f"{label}.command[{index}] references undeclared input "
445
+ f"`{logical_ref}`"
446
+ )
447
+ continue
448
+ raise HookError(
449
+ f"Unknown command placeholder {{{token}}} in {label}.command[{index}]"
450
+ )
451
+ if EMBEDDED_COMMAND_PLACEHOLDER_PATTERN.search(argument):
452
+ raise HookError(
453
+ f"Recognized command placeholders in {label}.command[{index}] "
454
+ "must be whole argv elements"
455
+ )
456
+
457
+ if "stdin" in step and step.get("stdin") not in step.get("inputs", []):
458
+ raise HookError(f"{label}.stdin must exactly match a declared input")
459
+
460
+
461
+ def _reject_legacy_step_type(step_type: str, label: str) -> None:
462
+ if step_type in {"llm", "agent"}:
463
+ raise HookError(
464
+ f"Legacy workflow step type `{step_type}` is not supported at {label}.type; "
465
+ "use `ask` instead"
466
+ )
467
+
468
+
121
469
  def _validate_config_types(raw: dict[str, Any]) -> None:
122
470
  unknown = set(raw) - ALLOWED_TOP_LEVEL_KEYS
123
471
  if unknown:
@@ -140,6 +488,12 @@ def _validate_config_types(raw: dict[str, Any]) -> None:
140
488
  _validate_unknown_keys(llm, LLM_KEYS, "llm")
141
489
  for key in ("runner", "model", "variant", "session_title_prefix"):
142
490
  _validate_string(llm, key, "llm")
491
+ _validate_non_empty_string(llm, "runner", "llm")
492
+ if "runner" in llm:
493
+ _validate_no_control_chars(llm["runner"], "llm.runner")
494
+ for key in ("model", "variant"):
495
+ if key in llm:
496
+ _validate_no_control_chars(llm[key], f"llm.{key}")
143
497
  for key in ("json_retry_new_session", "delete_session_after_run"):
144
498
  _validate_bool(llm, key, "llm")
145
499
  _validate_integer(llm, "timeout_seconds", "llm", minimum=1)
@@ -176,26 +530,66 @@ def _validate_config_types(raw: dict[str, Any]) -> None:
176
530
  _validate_unknown_keys(step, STEP_KEYS, label)
177
531
  for key in ("id", "type"):
178
532
  _validate_string(step, key, label)
533
+ if isinstance(step.get("type"), str):
534
+ _reject_legacy_step_type(step["type"].strip(), label)
179
535
  for key in (
180
536
  "collector",
181
537
  "executor",
182
538
  "assertion",
539
+ "python",
183
540
  "output",
184
541
  "schema",
185
542
  "prompt",
186
543
  "prompt_file",
187
544
  "fallback_prompt_id",
188
545
  "when_env",
546
+ "runner",
189
547
  ):
190
548
  _validate_string(step, key, label, allow_none=True)
191
- for key in ("inputs", "allow_paths"):
549
+ for key in ("inputs", "allow_paths", "command"):
192
550
  _validate_string_list(step, key, label)
551
+ if "options" in step and not isinstance(step["options"], dict):
552
+ raise HookError(f"{label}.options must be a table")
553
+ _validate_string(step, "stdin", label, allow_none=True)
554
+ _validate_integer(step, "timeout_seconds", label, minimum=1)
555
+ if "runner" in step and step["runner"] is not None and not step["runner"].strip():
556
+ raise HookError(f"{label}.runner must be a non-empty string")
557
+ if "runner" in step and step["runner"] is not None:
558
+ _validate_no_control_chars(step["runner"], f"{label}.runner")
559
+ if (
560
+ step.get("runner") is not None
561
+ and isinstance(step.get("type"), str)
562
+ and step["type"] in {"collect", "exec", "assert"}
563
+ ):
564
+ raise HookError(f"{label}.runner is only valid on ask and apply steps")
565
+ _validate_step_extensions(step, label)
566
+
567
+ def _normalize_runner_profile(name: str, raw: dict[str, Any]) -> RunnerProfile:
568
+ runner_type = str(raw["type"]).strip()
569
+ return RunnerProfile(
570
+ name=name,
571
+ type=runner_type,
572
+ model=str(raw["model"]) if raw.get("model") is not None else None,
573
+ variant=str(raw["variant"]) if raw.get("variant") is not None else None,
574
+ project_access=str(
575
+ raw.get(
576
+ "project_access",
577
+ "artifacts" if runner_type == "opencode" else "project",
578
+ )
579
+ ),
580
+ command=tuple(str(item) for item in raw.get("command", []) or []),
581
+ prompt_transport=str(raw.get("prompt_transport", "stdin")),
582
+ )
193
583
 
194
584
 
195
- def _normalize_step(raw: dict[str, Any]) -> StepConfig:
585
+ def _normalize_step(
586
+ raw: dict[str, Any], label: str, *, repo_root: pathlib.Path | None = None
587
+ ) -> StepConfig:
196
588
  step_type = str(raw.get("type", "")).strip()
589
+ _reject_legacy_step_type(step_type, label)
197
590
  if step_type not in SUPPORTED_STEP_TYPES:
198
- raise HookError(f"Unknown step type: {step_type}")
591
+ raise HookError(f"Unknown step type at {label}.type: {step_type}")
592
+ _validate_step_extensions(raw, label, repo_root=repo_root)
199
593
  step = StepConfig(
200
594
  id=str(raw.get("id", "")).strip(),
201
595
  type=step_type,
@@ -213,7 +607,17 @@ def _normalize_step(raw: dict[str, Any]) -> StepConfig:
213
607
  allow_paths=tuple(str(item) for item in raw.get("allow_paths", []) or []),
214
608
  executor=str(raw.get("executor")).strip() if raw.get("executor") is not None else None,
215
609
  assertion=str(raw.get("assertion")).strip() if raw.get("assertion") is not None else None,
610
+ python=str(raw.get("python")).strip() if raw.get("python") is not None else None,
611
+ options=dict(raw.get("options", {}) or {}),
612
+ command=tuple(str(item) for item in raw.get("command", []) or []),
613
+ stdin=str(raw.get("stdin")).strip() if raw.get("stdin") is not None else None,
614
+ timeout_seconds=(
615
+ int(raw["timeout_seconds"])
616
+ if raw.get("timeout_seconds") is not None
617
+ else (DEFAULT_STEP_COMMAND_TIMEOUT_SECONDS if raw.get("command") else None)
618
+ ),
216
619
  when_env=str(raw.get("when_env")).strip() if raw.get("when_env") is not None else None,
620
+ runner=str(raw.get("runner")).strip() if raw.get("runner") is not None else None,
217
621
  )
218
622
  if not step.id:
219
623
  raise HookError("Every workflow step requires a non-empty id")
@@ -228,23 +632,27 @@ def _normalize_step(raw: dict[str, Any]) -> StepConfig:
228
632
  raise HookError(f"Apply step `{step.id}` may not allow AGENTS.md")
229
633
  if step.is_promptable and not any([step.prompt, step.prompt_file, step.fallback_prompt_id]):
230
634
  raise HookError(f"Promptable step `{step.id}` requires prompt, prompt_file, or fallback_prompt_id")
231
- if step.type == "collect" and not step.collector:
232
- raise HookError(f"Collect step `{step.id}` requires collector")
233
- if step.type == "llm" and not step.output:
234
- raise HookError(f"LLM step `{step.id}` requires output")
635
+ if step.type == "collect" and not (step.collector or step.python):
636
+ raise HookError(f"Collect step `{step.id}` requires collector or python")
637
+ if step.type == "ask" and not step.output:
638
+ raise HookError(f"Ask step `{step.id}` requires output")
235
639
  if step.type == "apply" and not step.allow_paths:
236
640
  raise HookError(f"Apply step `{step.id}` requires allow_paths")
237
- if step.type == "exec" and not step.executor:
238
- raise HookError(f"Exec step `{step.id}` requires executor")
239
- if step.type == "assert" and not step.assertion:
240
- raise HookError(f"Assert step `{step.id}` requires assertion")
641
+ if step.type == "exec" and not (step.executor or step.python or step.command):
642
+ raise HookError(f"Exec step `{step.id}` requires executor, python, or command")
643
+ if step.type == "assert" and not (step.assertion or step.python or step.command):
644
+ raise HookError(f"Assert step `{step.id}` requires assertion, python, or command")
241
645
  return step
242
646
 
243
647
 
244
- def _build_config(raw: dict[str, Any]) -> HookConfig:
648
+ def _build_config(
649
+ raw: dict[str, Any], *, effective_model: str | None = None,
650
+ repo_root: pathlib.Path | None = None,
651
+ ) -> HookConfig:
245
652
  if not isinstance(raw, dict):
246
653
  raise HookError("Config document must contain a top-level table")
247
654
  _validate_config_types(raw)
655
+ _validate_runner_profiles(raw, effective_model)
248
656
  unknown = set(raw) - ALLOWED_TOP_LEVEL_KEYS
249
657
  if unknown:
250
658
  raise HookError(
@@ -271,12 +679,56 @@ def _build_config(raw: dict[str, Any]) -> HookConfig:
271
679
  modules[module_id] = ModuleConfig(
272
680
  id=module_id,
273
681
  enabled=bool(module_raw.get("enabled", True)),
274
- steps=tuple(_normalize_step(step) for step in steps_raw),
682
+ steps=tuple(
683
+ _normalize_step(
684
+ step,
685
+ f"modules.{module_id}.steps[{index}]",
686
+ # Repository filesystem checks are performed once below,
687
+ # after environment overrides have been resolved.
688
+ repo_root=None,
689
+ )
690
+ for index, step in enumerate(steps_raw, start=1)
691
+ ),
275
692
  )
276
693
 
694
+ # Validate repository-local callback paths in disabled/unselected modules too,
695
+ # while preserving the existing runtime model that only workflow modules are
696
+ # materialized in HookConfig.modules.
697
+ if repo_root is not None:
698
+ validated_python_references: set[str] = set()
699
+ for module_id, module_raw in module_payload.items():
700
+ for index, step_raw in enumerate(module_raw.get("steps", []) or [], start=1):
701
+ python_ref = step_raw.get("python")
702
+ if python_ref is not None:
703
+ if python_ref in validated_python_references:
704
+ continue
705
+ validated_python_references.add(python_ref)
706
+ _validate_python_reference(
707
+ python_ref,
708
+ f"modules.{module_id}.steps[{index}].python",
709
+ repo_root,
710
+ )
711
+
277
712
  general = GeneralConfig(**raw.get("general", {}))
278
713
  llm = LlmConfig(**raw.get("llm", {}))
279
714
  logging = LoggingConfig(**raw.get("logging", {}))
715
+ runner_payload = raw.get("runners", {})
716
+ runners = {
717
+ name: _normalize_runner_profile(name, profile)
718
+ for name, profile in runner_payload.items()
719
+ }
720
+ if llm.runner != "opencode" and llm.runner not in runners:
721
+ raise HookError(f"llm.runner references missing runner profile `{llm.runner}`")
722
+ for module_id, module_raw in module_payload.items():
723
+ for index, step_raw in enumerate(module_raw.get("steps", []) or [], start=1):
724
+ step_runner = step_raw.get("runner")
725
+ if step_runner is None or step_runner == "opencode":
726
+ continue
727
+ if step_runner not in runners:
728
+ raise HookError(
729
+ f"modules.{module_id}.steps[{index}].runner references missing runner profile "
730
+ f"`{step_runner}`"
731
+ )
280
732
  for label, storage_path in (
281
733
  ("logging.dir", logging.dir),
282
734
  ("logging.transcript_dir", logging.transcript_dir),
@@ -291,10 +743,63 @@ def _build_config(raw: dict[str, Any]) -> HookConfig:
291
743
  logging=logging,
292
744
  workflow=WorkflowConfig(modules=workflow_modules),
293
745
  modules=modules,
746
+ runners=runners,
747
+ )
748
+
749
+
750
+ def resolve_runner_profile(
751
+ config: HookConfig,
752
+ step: StepConfig,
753
+ env: Mapping[str, str] | None = None,
754
+ ) -> RunnerProfile:
755
+ """Resolve the runner selected by a promptable step and apply final env overrides."""
756
+ if step.type not in {"ask", "apply"} and step.runner is not None:
757
+ raise HookError(f"Step `{step.id}` may not select a runner")
758
+
759
+ selected_name = step.runner or config.llm.runner
760
+ profile = config.runners.get(selected_name)
761
+ if profile is None:
762
+ if selected_name != "opencode":
763
+ raise HookError(f"Runner profile `{selected_name}` does not exist")
764
+ profile = RunnerProfile(
765
+ name="opencode",
766
+ type="opencode",
767
+ model=config.llm.model,
768
+ variant=config.llm.variant,
769
+ project_access="artifacts",
770
+ )
771
+
772
+ environment = os.environ if env is None else env
773
+ model = profile.model
774
+ model_override = environment.get("AI_PUSH_HOOKS_MODEL")
775
+ _validate_model_override(model_override)
776
+ if model_override is not None:
777
+ model = model_override
778
+
779
+ variant = profile.variant
780
+ if profile.type == "opencode":
781
+ variant_override = environment.get("AI_PUSH_HOOKS_VARIANT")
782
+ _validate_variant_override(variant_override)
783
+ if variant_override is not None:
784
+ variant = variant_override.strip()
785
+
786
+ return RunnerProfile(
787
+ name=selected_name,
788
+ type=profile.type,
789
+ model=model,
790
+ variant=variant,
791
+ project_access=profile.project_access,
792
+ command=profile.command,
793
+ prompt_transport=profile.prompt_transport,
294
794
  )
295
795
 
296
796
 
297
- def _apply_env_overrides(config: HookConfig) -> HookConfig:
797
+ def _apply_env_overrides(
798
+ config: HookConfig,
799
+ *,
800
+ repo_root: pathlib.Path | None = None,
801
+ raw_modules: dict[str, Any] | None = None,
802
+ ) -> HookConfig:
298
803
  raw = {
299
804
  "general": {
300
805
  "enabled": config.general.enabled,
@@ -306,13 +811,51 @@ def _apply_env_overrides(config: HookConfig) -> HookConfig:
306
811
  "llm": config.llm.__dict__.copy(),
307
812
  "logging": config.logging.__dict__.copy(),
308
813
  "workflow": {"modules": list(config.workflow.modules)},
309
- "modules": {},
814
+ "modules": {},
815
+ "runners": {},
310
816
  }
311
817
  for module_id, module in config.modules.items():
818
+ step_payloads: list[dict[str, Any]] = []
819
+ for step in module.steps:
820
+ step_payload = step.__dict__.copy()
821
+ for key in (
822
+ "collector",
823
+ "executor",
824
+ "assertion",
825
+ "python",
826
+ "stdin",
827
+ "timeout_seconds",
828
+ "when_env",
829
+ "runner",
830
+ ):
831
+ if step_payload[key] is None:
832
+ step_payload.pop(key)
833
+ if not step_payload["options"]:
834
+ step_payload.pop("options")
835
+ if not step_payload["command"]:
836
+ step_payload.pop("command")
837
+ step_payloads.append(step_payload)
312
838
  raw["modules"][module_id] = {
313
839
  "enabled": module.enabled,
314
- "steps": [step.__dict__.copy() for step in module.steps],
840
+ "steps": step_payloads,
841
+ }
842
+ if raw_modules is not None:
843
+ # Keep disabled and unselected modules in the final build so their
844
+ # callback references are validated without materializing them.
845
+ raw["modules"] = raw_modules
846
+ for name, profile in config.runners.items():
847
+ runner_raw: dict[str, Any] = {
848
+ "type": profile.type,
849
+ "project_access": profile.project_access,
315
850
  }
851
+ if profile.model is not None:
852
+ runner_raw["model"] = profile.model
853
+ if profile.variant is not None:
854
+ runner_raw["variant"] = profile.variant
855
+ if profile.type == "command":
856
+ runner_raw["command"] = list(profile.command)
857
+ runner_raw["prompt_transport"] = profile.prompt_transport
858
+ raw["runners"][name] = runner_raw
316
859
 
317
860
  def read_env_bool(name: str) -> bool | None:
318
861
  value = os.getenv(name)
@@ -348,9 +891,11 @@ def _apply_env_overrides(config: HookConfig) -> HookConfig:
348
891
  if print_output is not None:
349
892
  raw["logging"]["print_llm_output"] = print_output
350
893
  model = os.getenv("AI_PUSH_HOOKS_MODEL")
351
- if model:
894
+ _validate_model_override(model)
895
+ if model is not None:
352
896
  raw["llm"]["model"] = model
353
897
  variant = os.getenv("AI_PUSH_HOOKS_VARIANT")
898
+ _validate_variant_override(variant)
354
899
  if variant is not None:
355
900
  raw["llm"]["variant"] = variant.strip()
356
901
  timeout = os.getenv("AI_PUSH_HOOKS_TIMEOUT_SECONDS")
@@ -368,7 +913,7 @@ def _apply_env_overrides(config: HookConfig) -> HookConfig:
368
913
  "must be at least 1"
369
914
  )
370
915
  raw["llm"]["timeout_seconds"] = parsed_timeout
371
- return _build_config(raw)
916
+ return _build_config(raw, effective_model=model, repo_root=repo_root)
372
917
 
373
918
 
374
919
  def load_config(repo_root: pathlib.Path) -> tuple[HookConfig, pathlib.Path]:
@@ -393,7 +938,15 @@ def load_config(repo_root: pathlib.Path) -> tuple[HookConfig, pathlib.Path]:
393
938
  raise HookError(f"Invalid TOML in {config_path}{location}: {exc}") from exc
394
939
  if not isinstance(loaded, dict):
395
940
  raise HookError(f"Invalid config format in {config_path}: expected a top-level table")
396
- return _apply_env_overrides(_build_config(loaded)), config_path
941
+ model_override = os.getenv("AI_PUSH_HOOKS_MODEL")
942
+ variant_override = os.getenv("AI_PUSH_HOOKS_VARIANT")
943
+ _validate_model_override(model_override)
944
+ _validate_variant_override(variant_override)
945
+ return _apply_env_overrides(
946
+ _build_config(loaded, effective_model=model_override, repo_root=None),
947
+ repo_root=repo_root,
948
+ raw_modules=loaded.get("modules", {}),
949
+ ), config_path
397
950
 
398
951
 
399
952
  def resolve_prompt_text(repo_root: pathlib.Path, step: StepConfig) -> str: