ai-push-hooks 0.2.0 → 0.3.0

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.
@@ -1,8 +1,11 @@
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
11
  from .executors.exec import env_bool, resolve_git_common_dir, resolve_git_dir
@@ -15,14 +18,25 @@ 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
+ SUPPORTED_STEP_TYPES,
23
+ GeneralConfig,
24
+ HookConfig,
25
+ HookError,
26
+ LlmConfig,
27
+ LoggingConfig,
28
+ ModuleConfig,
29
+ RunnerProfile,
30
+ StepConfig,
31
+ WorkflowConfig,
32
+ )
19
33
 
20
34
  try:
21
35
  import tomllib
22
36
  except ModuleNotFoundError: # pragma: no cover
23
37
  import tomli as tomllib
24
38
 
25
- ALLOWED_TOP_LEVEL_KEYS = {"general", "llm", "logging", "workflow", "modules"}
39
+ ALLOWED_TOP_LEVEL_KEYS = {"general", "llm", "logging", "workflow", "modules", "runners"}
26
40
  STORAGE_NAMESPACE_PARTS = (".git", "ai-push-hooks")
27
41
  GENERAL_KEYS = {
28
42
  "enabled",
@@ -66,8 +80,34 @@ STEP_KEYS = {
66
80
  "allow_paths",
67
81
  "executor",
68
82
  "assertion",
83
+ "python",
84
+ "options",
85
+ "command",
86
+ "stdin",
87
+ "timeout_seconds",
69
88
  "when_env",
89
+ "runner",
70
90
  }
91
+ RUNNER_TYPES = frozenset({"opencode", "codex", "claude", "command"})
92
+ PROJECT_ACCESS_VALUES = frozenset({"artifacts", "project"})
93
+ PROMPT_TRANSPORT_VALUES = frozenset({"stdin", "argv"})
94
+ RUNNER_KEYS = {
95
+ "type",
96
+ "model",
97
+ "variant",
98
+ "project_access",
99
+ "command",
100
+ "prompt_transport",
101
+ }
102
+ RUNNER_PLACEHOLDERS = frozenset({"{prompt}", "{model}", "{cwd}", "{stage}"})
103
+ PYTHON_CALLABLE_PATTERN = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\Z")
104
+ COMMAND_PLACEHOLDER_PATTERN = re.compile(r"\{([A-Za-z0-9_./:-]+)\}\Z")
105
+ EMBEDDED_COMMAND_PLACEHOLDER_PATTERN = re.compile(
106
+ r"\{(?:repo|python|input:[A-Za-z0-9_./:-]+)\}"
107
+ )
108
+ COMMAND_PLACEHOLDER_NAMES = frozenset({"repo", "python"})
109
+ DEFAULT_STEP_COMMAND_TIMEOUT_SECONDS = 60
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,50 @@ 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
+ repo_root=repo_root,
687
+ )
688
+ for index, step in enumerate(steps_raw, start=1)
689
+ ),
275
690
  )
276
691
 
692
+ # Validate repository-local callback paths in disabled/unselected modules too,
693
+ # while preserving the existing runtime model that only workflow modules are
694
+ # materialized in HookConfig.modules.
695
+ if repo_root is not None:
696
+ for module_id, module_raw in module_payload.items():
697
+ for index, step_raw in enumerate(module_raw.get("steps", []) or [], start=1):
698
+ python_ref = step_raw.get("python")
699
+ if python_ref is not None:
700
+ _validate_python_reference(
701
+ python_ref,
702
+ f"modules.{module_id}.steps[{index}].python",
703
+ repo_root,
704
+ )
705
+
277
706
  general = GeneralConfig(**raw.get("general", {}))
278
707
  llm = LlmConfig(**raw.get("llm", {}))
279
708
  logging = LoggingConfig(**raw.get("logging", {}))
709
+ runner_payload = raw.get("runners", {})
710
+ runners = {
711
+ name: _normalize_runner_profile(name, profile)
712
+ for name, profile in runner_payload.items()
713
+ }
714
+ if llm.runner != "opencode" and llm.runner not in runners:
715
+ raise HookError(f"llm.runner references missing runner profile `{llm.runner}`")
716
+ for module_id, module_raw in module_payload.items():
717
+ for index, step_raw in enumerate(module_raw.get("steps", []) or [], start=1):
718
+ step_runner = step_raw.get("runner")
719
+ if step_runner is None or step_runner == "opencode":
720
+ continue
721
+ if step_runner not in runners:
722
+ raise HookError(
723
+ f"modules.{module_id}.steps[{index}].runner references missing runner profile "
724
+ f"`{step_runner}`"
725
+ )
280
726
  for label, storage_path in (
281
727
  ("logging.dir", logging.dir),
282
728
  ("logging.transcript_dir", logging.transcript_dir),
@@ -291,10 +737,60 @@ def _build_config(raw: dict[str, Any]) -> HookConfig:
291
737
  logging=logging,
292
738
  workflow=WorkflowConfig(modules=workflow_modules),
293
739
  modules=modules,
740
+ runners=runners,
741
+ )
742
+
743
+
744
+ def resolve_runner_profile(
745
+ config: HookConfig,
746
+ step: StepConfig,
747
+ env: Mapping[str, str] | None = None,
748
+ ) -> RunnerProfile:
749
+ """Resolve the runner selected by a promptable step and apply final env overrides."""
750
+ if step.type not in {"ask", "apply"} and step.runner is not None:
751
+ raise HookError(f"Step `{step.id}` may not select a runner")
752
+
753
+ selected_name = step.runner or config.llm.runner
754
+ profile = config.runners.get(selected_name)
755
+ if profile is None:
756
+ if selected_name != "opencode":
757
+ raise HookError(f"Runner profile `{selected_name}` does not exist")
758
+ profile = RunnerProfile(
759
+ name="opencode",
760
+ type="opencode",
761
+ model=config.llm.model,
762
+ variant=config.llm.variant,
763
+ project_access="artifacts",
764
+ )
765
+
766
+ environment = os.environ if env is None else env
767
+ model = profile.model
768
+ model_override = environment.get("AI_PUSH_HOOKS_MODEL")
769
+ _validate_model_override(model_override)
770
+ if model_override is not None:
771
+ model = model_override
772
+
773
+ variant = profile.variant
774
+ if profile.type == "opencode":
775
+ variant_override = environment.get("AI_PUSH_HOOKS_VARIANT")
776
+ _validate_variant_override(variant_override)
777
+ if variant_override is not None:
778
+ variant = variant_override.strip()
779
+
780
+ return RunnerProfile(
781
+ name=selected_name,
782
+ type=profile.type,
783
+ model=model,
784
+ variant=variant,
785
+ project_access=profile.project_access,
786
+ command=profile.command,
787
+ prompt_transport=profile.prompt_transport,
294
788
  )
295
789
 
296
790
 
297
- def _apply_env_overrides(config: HookConfig) -> HookConfig:
791
+ def _apply_env_overrides(
792
+ config: HookConfig, *, repo_root: pathlib.Path | None = None
793
+ ) -> HookConfig:
298
794
  raw = {
299
795
  "general": {
300
796
  "enabled": config.general.enabled,
@@ -306,13 +802,47 @@ def _apply_env_overrides(config: HookConfig) -> HookConfig:
306
802
  "llm": config.llm.__dict__.copy(),
307
803
  "logging": config.logging.__dict__.copy(),
308
804
  "workflow": {"modules": list(config.workflow.modules)},
309
- "modules": {},
805
+ "modules": {},
806
+ "runners": {},
310
807
  }
311
808
  for module_id, module in config.modules.items():
809
+ step_payloads: list[dict[str, Any]] = []
810
+ for step in module.steps:
811
+ step_payload = step.__dict__.copy()
812
+ for key in (
813
+ "collector",
814
+ "executor",
815
+ "assertion",
816
+ "python",
817
+ "stdin",
818
+ "timeout_seconds",
819
+ "when_env",
820
+ "runner",
821
+ ):
822
+ if step_payload[key] is None:
823
+ step_payload.pop(key)
824
+ if not step_payload["options"]:
825
+ step_payload.pop("options")
826
+ if not step_payload["command"]:
827
+ step_payload.pop("command")
828
+ step_payloads.append(step_payload)
312
829
  raw["modules"][module_id] = {
313
830
  "enabled": module.enabled,
314
- "steps": [step.__dict__.copy() for step in module.steps],
831
+ "steps": step_payloads,
832
+ }
833
+ for name, profile in config.runners.items():
834
+ runner_raw: dict[str, Any] = {
835
+ "type": profile.type,
836
+ "project_access": profile.project_access,
315
837
  }
838
+ if profile.model is not None:
839
+ runner_raw["model"] = profile.model
840
+ if profile.variant is not None:
841
+ runner_raw["variant"] = profile.variant
842
+ if profile.type == "command":
843
+ runner_raw["command"] = list(profile.command)
844
+ runner_raw["prompt_transport"] = profile.prompt_transport
845
+ raw["runners"][name] = runner_raw
316
846
 
317
847
  def read_env_bool(name: str) -> bool | None:
318
848
  value = os.getenv(name)
@@ -348,9 +878,11 @@ def _apply_env_overrides(config: HookConfig) -> HookConfig:
348
878
  if print_output is not None:
349
879
  raw["logging"]["print_llm_output"] = print_output
350
880
  model = os.getenv("AI_PUSH_HOOKS_MODEL")
351
- if model:
881
+ _validate_model_override(model)
882
+ if model is not None:
352
883
  raw["llm"]["model"] = model
353
884
  variant = os.getenv("AI_PUSH_HOOKS_VARIANT")
885
+ _validate_variant_override(variant)
354
886
  if variant is not None:
355
887
  raw["llm"]["variant"] = variant.strip()
356
888
  timeout = os.getenv("AI_PUSH_HOOKS_TIMEOUT_SECONDS")
@@ -368,7 +900,7 @@ def _apply_env_overrides(config: HookConfig) -> HookConfig:
368
900
  "must be at least 1"
369
901
  )
370
902
  raw["llm"]["timeout_seconds"] = parsed_timeout
371
- return _build_config(raw)
903
+ return _build_config(raw, effective_model=model, repo_root=repo_root)
372
904
 
373
905
 
374
906
  def load_config(repo_root: pathlib.Path) -> tuple[HookConfig, pathlib.Path]:
@@ -393,7 +925,14 @@ def load_config(repo_root: pathlib.Path) -> tuple[HookConfig, pathlib.Path]:
393
925
  raise HookError(f"Invalid TOML in {config_path}{location}: {exc}") from exc
394
926
  if not isinstance(loaded, dict):
395
927
  raise HookError(f"Invalid config format in {config_path}: expected a top-level table")
396
- return _apply_env_overrides(_build_config(loaded)), config_path
928
+ model_override = os.getenv("AI_PUSH_HOOKS_MODEL")
929
+ variant_override = os.getenv("AI_PUSH_HOOKS_VARIANT")
930
+ _validate_model_override(model_override)
931
+ _validate_variant_override(variant_override)
932
+ return _apply_env_overrides(
933
+ _build_config(loaded, effective_model=model_override, repo_root=repo_root),
934
+ repo_root=repo_root,
935
+ ), config_path
397
936
 
398
937
 
399
938
  def resolve_prompt_text(repo_root: pathlib.Path, step: StepConfig) -> str: