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.
- package/CHANGELOG.md +68 -2
- package/README.md +582 -85
- package/SECURITY.md +96 -13
- package/ai-push-hooks.toml +9 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/ai_push_hooks/artifacts.py +67 -0
- package/src/ai_push_hooks/config.py +560 -21
- package/src/ai_push_hooks/engine.py +114 -6
- package/src/ai_push_hooks/executors/apply.py +73 -34
- package/src/ai_push_hooks/executors/{llm.py → ask.py} +197 -75
- package/src/ai_push_hooks/executors/exec.py +9 -1
- package/src/ai_push_hooks/executors/runner_workflow.py +478 -0
- package/src/ai_push_hooks/executors/runners/__init__.py +78 -0
- package/src/ai_push_hooks/executors/runners/claude.py +286 -0
- package/src/ai_push_hooks/executors/runners/codex.py +254 -0
- package/src/ai_push_hooks/executors/runners/command.py +178 -0
- package/src/ai_push_hooks/executors/runners/contracts.py +597 -0
- package/src/ai_push_hooks/executors/runners/opencode.py +499 -0
- package/src/ai_push_hooks/executors/runners/process.py +403 -0
- package/src/ai_push_hooks/executors/runners/registry.py +104 -0
- package/src/ai_push_hooks/executors/step_commands.py +473 -0
- package/src/ai_push_hooks/plugin_loader.py +398 -0
- package/src/ai_push_hooks/plugins.py +134 -0
- package/src/ai_push_hooks/prompts_builtin.py +9 -2
- package/src/ai_push_hooks/types.py +406 -75
|
@@ -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
|
|
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(
|
|
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 == "
|
|
234
|
-
raise HookError(f"
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
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
|
-
|
|
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:
|