ai-push-hooks 0.1.19 → 0.2.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 +63 -0
- package/README.md +296 -62
- package/SECURITY.md +35 -0
- package/ai-push-hooks.toml +1 -1
- package/bin/ai-push-hooks.js +20 -5
- package/package.json +25 -4
- package/pyproject.toml +11 -4
- package/src/ai_push_hooks/artifacts.py +57 -9
- package/src/ai_push_hooks/cli.py +60 -3
- package/src/ai_push_hooks/config.py +281 -15
- package/src/ai_push_hooks/engine.py +0 -2
- package/src/ai_push_hooks/executors/apply.py +845 -43
- package/src/ai_push_hooks/executors/exec.py +734 -114
- package/src/ai_push_hooks/executors/llm.py +369 -42
- package/src/ai_push_hooks/hook.py +129 -22
- package/src/ai_push_hooks/install.py +205 -0
- package/src/ai_push_hooks/modules/beads.py +21 -6
- package/src/ai_push_hooks/modules/docs.py +147 -27
- package/src/ai_push_hooks/modules/pr.py +53 -7
- package/src/ai_push_hooks/paths.py +182 -0
- package/src/ai_push_hooks/prompts_builtin.py +6 -2
- package/src/ai_push_hooks/types.py +82 -3
package/pyproject.toml
CHANGED
|
@@ -4,25 +4,32 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ai-push-hooks"
|
|
7
|
-
version = "0.
|
|
8
|
-
description = "
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Run structured AI-assisted checks and allowlisted maintenance before git push"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
11
11
|
license = "MIT"
|
|
12
|
-
authors = [{ name = "
|
|
12
|
+
authors = [{ name = "Shane Bishop" }]
|
|
13
13
|
keywords = ["git", "lefthook", "docs", "ai", "pre-push"]
|
|
14
14
|
classifiers = [
|
|
15
|
-
"Development Status ::
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
16
|
"Intended Audience :: Developers",
|
|
17
17
|
"Programming Language :: Python :: 3",
|
|
18
18
|
"Programming Language :: Python :: 3 :: Only",
|
|
19
19
|
"Programming Language :: Python :: 3.10",
|
|
20
20
|
"Programming Language :: Python :: 3.11",
|
|
21
21
|
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
22
23
|
"Topic :: Software Development :: Version Control :: Git",
|
|
23
24
|
]
|
|
24
25
|
dependencies = ["tomli>=2.0.0; python_version < '3.11'"]
|
|
25
26
|
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/shanebishop1/ai-push-hooks"
|
|
29
|
+
Repository = "https://github.com/shanebishop1/ai-push-hooks.git"
|
|
30
|
+
Issues = "https://github.com/shanebishop1/ai-push-hooks/issues"
|
|
31
|
+
Changelog = "https://github.com/shanebishop1/ai-push-hooks/blob/main/CHANGELOG.md"
|
|
32
|
+
|
|
26
33
|
[project.scripts]
|
|
27
34
|
ai-push-hooks = "ai_push_hooks.cli:main"
|
|
28
35
|
|
|
@@ -6,6 +6,15 @@ from datetime import datetime, timezone
|
|
|
6
6
|
from typing import Any
|
|
7
7
|
from uuid import uuid4
|
|
8
8
|
|
|
9
|
+
from .paths import (
|
|
10
|
+
ensure_private_directory,
|
|
11
|
+
is_path_within,
|
|
12
|
+
path_has_symlink,
|
|
13
|
+
path_is_link_or_reparse,
|
|
14
|
+
resolve_contained_path,
|
|
15
|
+
validate_path_component,
|
|
16
|
+
write_text_no_follow,
|
|
17
|
+
)
|
|
9
18
|
from .types import HookError, ModuleRuntimeState
|
|
10
19
|
|
|
11
20
|
|
|
@@ -19,13 +28,42 @@ class ArtifactStore:
|
|
|
19
28
|
self.run_dir = run_dir
|
|
20
29
|
|
|
21
30
|
def prepare(self) -> pathlib.Path:
|
|
22
|
-
self.run_dir
|
|
23
|
-
|
|
31
|
+
if path_is_link_or_reparse(self.run_dir):
|
|
32
|
+
raise HookError(
|
|
33
|
+
f"Artifact run directory must not be a symlink or reparse point: {self.run_dir}"
|
|
34
|
+
)
|
|
35
|
+
return ensure_private_directory(self.run_dir)
|
|
24
36
|
|
|
25
37
|
def step_dir(self, module_id: str, step_index: int, step_id: str) -> pathlib.Path:
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
38
|
+
module_name = validate_path_component(module_id, "Artifact module id")
|
|
39
|
+
step_name = validate_path_component(step_id, "Artifact step id")
|
|
40
|
+
lexical_module_path = self.run_dir / module_name
|
|
41
|
+
if path_has_symlink(self.run_dir, lexical_module_path):
|
|
42
|
+
raise HookError(f"Artifact module path must not traverse a symlink: {module_id}")
|
|
43
|
+
module_path = resolve_contained_path(self.run_dir, module_name, "Artifact module path")
|
|
44
|
+
lexical_step_path = module_path / f"{step_index:02d}-{step_name}"
|
|
45
|
+
if path_has_symlink(self.run_dir, lexical_step_path):
|
|
46
|
+
raise HookError(f"Artifact step path must not traverse a symlink: {step_id}")
|
|
47
|
+
path = resolve_contained_path(
|
|
48
|
+
module_path,
|
|
49
|
+
f"{step_index:02d}-{step_name}",
|
|
50
|
+
"Artifact step path",
|
|
51
|
+
)
|
|
52
|
+
return ensure_private_directory(path)
|
|
53
|
+
|
|
54
|
+
def _artifact_path(
|
|
55
|
+
self,
|
|
56
|
+
module_id: str,
|
|
57
|
+
step_index: int,
|
|
58
|
+
step_id: str,
|
|
59
|
+
artifact_name: str,
|
|
60
|
+
) -> pathlib.Path:
|
|
61
|
+
name = validate_path_component(artifact_name, "Artifact name")
|
|
62
|
+
return resolve_contained_path(
|
|
63
|
+
self.step_dir(module_id, step_index, step_id),
|
|
64
|
+
name,
|
|
65
|
+
"Artifact output path",
|
|
66
|
+
)
|
|
29
67
|
|
|
30
68
|
def register(
|
|
31
69
|
self,
|
|
@@ -34,6 +72,13 @@ class ArtifactStore:
|
|
|
34
72
|
artifact_name: str,
|
|
35
73
|
path: pathlib.Path,
|
|
36
74
|
) -> pathlib.Path:
|
|
75
|
+
validate_path_component(step_id, "Artifact step id")
|
|
76
|
+
validate_path_component(artifact_name, "Artifact name")
|
|
77
|
+
resolved_run_dir = self.run_dir.resolve(strict=False)
|
|
78
|
+
if path_has_symlink(self.run_dir, path):
|
|
79
|
+
raise HookError(f"Artifact path must not traverse a symlink: {path}")
|
|
80
|
+
if not is_path_within(path.resolve(strict=False), resolved_run_dir):
|
|
81
|
+
raise HookError(f"Artifact path escapes run directory: {path}")
|
|
37
82
|
state.artifacts[f"{step_id}/{artifact_name}"] = path
|
|
38
83
|
return path
|
|
39
84
|
|
|
@@ -45,8 +90,8 @@ class ArtifactStore:
|
|
|
45
90
|
artifact_name: str,
|
|
46
91
|
content: str,
|
|
47
92
|
) -> pathlib.Path:
|
|
48
|
-
path = self.
|
|
49
|
-
path
|
|
93
|
+
path = self._artifact_path(state.module.id, step_index, step_id, artifact_name)
|
|
94
|
+
write_text_no_follow(path, content)
|
|
50
95
|
return self.register(state, step_id, artifact_name, path)
|
|
51
96
|
|
|
52
97
|
def write_json(
|
|
@@ -57,8 +102,8 @@ class ArtifactStore:
|
|
|
57
102
|
artifact_name: str,
|
|
58
103
|
payload: Any,
|
|
59
104
|
) -> pathlib.Path:
|
|
60
|
-
path = self.
|
|
61
|
-
path
|
|
105
|
+
path = self._artifact_path(state.module.id, step_index, step_id, artifact_name)
|
|
106
|
+
write_text_no_follow(path, json.dumps(payload, ensure_ascii=True, indent=2) + "\n")
|
|
62
107
|
return self.register(state, step_id, artifact_name, path)
|
|
63
108
|
|
|
64
109
|
def resolve_input(self, state: ModuleRuntimeState, reference: str) -> pathlib.Path:
|
|
@@ -91,4 +136,7 @@ class ArtifactStore:
|
|
|
91
136
|
artifact_name: str,
|
|
92
137
|
path: pathlib.Path,
|
|
93
138
|
) -> None:
|
|
139
|
+
validate_path_component(module_id, "Artifact module id")
|
|
140
|
+
validate_path_component(step_id, "Artifact step id")
|
|
141
|
+
validate_path_component(artifact_name, "Artifact name")
|
|
94
142
|
state.artifacts[f"{module_id}:{step_id}/{artifact_name}"] = path
|
package/src/ai_push_hooks/cli.py
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
|
+
import os
|
|
4
5
|
import pathlib
|
|
6
|
+
import stat
|
|
5
7
|
import sys
|
|
6
8
|
|
|
7
9
|
from .hook import run_hook
|
|
10
|
+
from .install import install_hook
|
|
11
|
+
from .paths import path_is_link_or_reparse, write_text_no_follow
|
|
8
12
|
from .prompts_builtin import MINIMAL_DOCS_TEMPLATE
|
|
9
13
|
from .types import HookError
|
|
10
14
|
|
|
@@ -20,6 +24,11 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
20
24
|
init_parser = subparsers.add_parser("init", help="Write a starter config")
|
|
21
25
|
init_parser.add_argument("--template", default="minimal-docs")
|
|
22
26
|
init_parser.add_argument("--force", action="store_true")
|
|
27
|
+
|
|
28
|
+
install_parser = subparsers.add_parser(
|
|
29
|
+
"install", help="Install a repo-local pre-push hook"
|
|
30
|
+
)
|
|
31
|
+
install_parser.add_argument("--force", action="store_true")
|
|
23
32
|
return parser
|
|
24
33
|
|
|
25
34
|
|
|
@@ -28,9 +37,55 @@ def init_config(template: str, force: bool, cwd: pathlib.Path | None = None) ->
|
|
|
28
37
|
raise HookError("Only `minimal-docs` is supported")
|
|
29
38
|
target_dir = cwd or pathlib.Path.cwd()
|
|
30
39
|
config_path = target_dir / "ai-push-hooks.toml"
|
|
31
|
-
if
|
|
32
|
-
|
|
33
|
-
|
|
40
|
+
if force:
|
|
41
|
+
try:
|
|
42
|
+
metadata = config_path.lstat()
|
|
43
|
+
except FileNotFoundError:
|
|
44
|
+
metadata = None
|
|
45
|
+
except OSError as exc:
|
|
46
|
+
raise HookError(f"Could not inspect config path {config_path}: {exc}") from exc
|
|
47
|
+
if metadata is not None:
|
|
48
|
+
if path_is_link_or_reparse(config_path):
|
|
49
|
+
raise HookError(f"Refusing to replace symlink or reparse point: {config_path}")
|
|
50
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
51
|
+
raise HookError(f"Refusing to replace non-regular config path: {config_path}")
|
|
52
|
+
try:
|
|
53
|
+
write_text_no_follow(config_path, MINIMAL_DOCS_TEMPLATE)
|
|
54
|
+
except HookError:
|
|
55
|
+
raise
|
|
56
|
+
except OSError as exc:
|
|
57
|
+
raise HookError(f"Could not write config file {config_path}: {exc}") from exc
|
|
58
|
+
else:
|
|
59
|
+
try:
|
|
60
|
+
metadata = config_path.lstat()
|
|
61
|
+
except FileNotFoundError:
|
|
62
|
+
metadata = None
|
|
63
|
+
except OSError as exc:
|
|
64
|
+
raise HookError(f"Could not inspect config path {config_path}: {exc}") from exc
|
|
65
|
+
if metadata is not None:
|
|
66
|
+
if path_is_link_or_reparse(config_path):
|
|
67
|
+
raise HookError(f"Refusing to overwrite symlink or reparse point: {config_path}")
|
|
68
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
69
|
+
raise HookError(f"Refusing to overwrite non-regular config path: {config_path}")
|
|
70
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0)
|
|
71
|
+
flags |= getattr(os, "O_NOFOLLOW", 0)
|
|
72
|
+
try:
|
|
73
|
+
descriptor = os.open(config_path, flags, 0o600)
|
|
74
|
+
except FileExistsError as exc:
|
|
75
|
+
raise HookError(
|
|
76
|
+
f"Refusing to overwrite existing config without --force: {config_path}"
|
|
77
|
+
) from exc
|
|
78
|
+
except OSError as exc:
|
|
79
|
+
raise HookError(f"Could not create config file {config_path}: {exc}") from exc
|
|
80
|
+
try:
|
|
81
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
82
|
+
descriptor = -1
|
|
83
|
+
handle.write(MINIMAL_DOCS_TEMPLATE)
|
|
84
|
+
handle.flush()
|
|
85
|
+
os.fsync(handle.fileno())
|
|
86
|
+
finally:
|
|
87
|
+
if descriptor >= 0:
|
|
88
|
+
os.close(descriptor)
|
|
34
89
|
sys.stdout.write(str(config_path) + "\n")
|
|
35
90
|
return 0
|
|
36
91
|
|
|
@@ -43,6 +98,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
43
98
|
return run_hook(args.remote_name, args.remote_url)
|
|
44
99
|
if args.command == "init":
|
|
45
100
|
return init_config(args.template, args.force)
|
|
101
|
+
if args.command == "install":
|
|
102
|
+
return install_hook(args.force)
|
|
46
103
|
raise HookError(f"Unknown command: {args.command}")
|
|
47
104
|
except HookError as exc:
|
|
48
105
|
sys.stderr.write(f"[ai-push-hooks] {exc}\n")
|
|
@@ -2,9 +2,18 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
import pathlib
|
|
5
|
+
import stat
|
|
5
6
|
from typing import Any
|
|
6
7
|
|
|
7
|
-
from .executors.exec import env_bool
|
|
8
|
+
from .executors.exec import env_bool, resolve_git_common_dir, resolve_git_dir
|
|
9
|
+
from .paths import (
|
|
10
|
+
is_path_within,
|
|
11
|
+
normalized_component,
|
|
12
|
+
path_has_symlink,
|
|
13
|
+
relative_path_parts,
|
|
14
|
+
resolve_contained_path,
|
|
15
|
+
validate_path_component,
|
|
16
|
+
)
|
|
8
17
|
from .prompts_builtin import BUILTIN_PROMPTS
|
|
9
18
|
from .types import GeneralConfig, HookConfig, HookError, LlmConfig, LoggingConfig, ModuleConfig, StepConfig, SUPPORTED_STEP_TYPES, WorkflowConfig
|
|
10
19
|
|
|
@@ -14,6 +23,173 @@ except ModuleNotFoundError: # pragma: no cover
|
|
|
14
23
|
import tomli as tomllib
|
|
15
24
|
|
|
16
25
|
ALLOWED_TOP_LEVEL_KEYS = {"general", "llm", "logging", "workflow", "modules"}
|
|
26
|
+
STORAGE_NAMESPACE_PARTS = (".git", "ai-push-hooks")
|
|
27
|
+
GENERAL_KEYS = {
|
|
28
|
+
"enabled",
|
|
29
|
+
"allow_push_on_error",
|
|
30
|
+
"require_clean_worktree",
|
|
31
|
+
"skip_on_sync_branch",
|
|
32
|
+
"base_branch",
|
|
33
|
+
}
|
|
34
|
+
LLM_KEYS = {
|
|
35
|
+
"runner",
|
|
36
|
+
"model",
|
|
37
|
+
"variant",
|
|
38
|
+
"timeout_seconds",
|
|
39
|
+
"max_parallel",
|
|
40
|
+
"json_max_retries",
|
|
41
|
+
"invalid_json_feedback_max_chars",
|
|
42
|
+
"json_retry_new_session",
|
|
43
|
+
"delete_session_after_run",
|
|
44
|
+
"max_diff_bytes",
|
|
45
|
+
"session_title_prefix",
|
|
46
|
+
}
|
|
47
|
+
LOGGING_KEYS = {
|
|
48
|
+
"level",
|
|
49
|
+
"jsonl",
|
|
50
|
+
"dir",
|
|
51
|
+
"capture_llm_transcript",
|
|
52
|
+
"transcript_dir",
|
|
53
|
+
"summary_dir",
|
|
54
|
+
"print_llm_output",
|
|
55
|
+
}
|
|
56
|
+
STEP_KEYS = {
|
|
57
|
+
"id",
|
|
58
|
+
"type",
|
|
59
|
+
"inputs",
|
|
60
|
+
"output",
|
|
61
|
+
"schema",
|
|
62
|
+
"prompt",
|
|
63
|
+
"prompt_file",
|
|
64
|
+
"fallback_prompt_id",
|
|
65
|
+
"collector",
|
|
66
|
+
"allow_paths",
|
|
67
|
+
"executor",
|
|
68
|
+
"assertion",
|
|
69
|
+
"when_env",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _require_table(value: Any, label: str) -> dict[str, Any]:
|
|
74
|
+
if not isinstance(value, dict):
|
|
75
|
+
raise HookError(f"{label} must be a table")
|
|
76
|
+
return value
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _validate_unknown_keys(table: dict[str, Any], allowed: set[str], label: str) -> None:
|
|
80
|
+
unknown = set(table) - allowed
|
|
81
|
+
if unknown:
|
|
82
|
+
raise HookError(f"Unknown field(s) in {label}: {', '.join(sorted(unknown))}")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _validate_bool(table: dict[str, Any], key: str, label: str) -> None:
|
|
86
|
+
if key in table and type(table[key]) is not bool:
|
|
87
|
+
raise HookError(f"{label}.{key} must be a TOML boolean")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _validate_string(
|
|
91
|
+
table: dict[str, Any], key: str, label: str, *, allow_none: bool = False
|
|
92
|
+
) -> None:
|
|
93
|
+
if key in table and (table[key] is None and allow_none):
|
|
94
|
+
return
|
|
95
|
+
if key in table and not isinstance(table[key], str):
|
|
96
|
+
raise HookError(f"{label}.{key} must be a string")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _validate_string_list(table: dict[str, Any], key: str, label: str) -> None:
|
|
100
|
+
if key not in table:
|
|
101
|
+
return
|
|
102
|
+
value = table[key]
|
|
103
|
+
if not isinstance(value, (list, tuple)) or any(
|
|
104
|
+
not isinstance(item, str) for item in value
|
|
105
|
+
):
|
|
106
|
+
raise HookError(f"{label}.{key} must be an array of strings")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _validate_integer(
|
|
110
|
+
table: dict[str, Any], key: str, label: str, *, minimum: int | None = None
|
|
111
|
+
) -> None:
|
|
112
|
+
if key not in table:
|
|
113
|
+
return
|
|
114
|
+
value = table[key]
|
|
115
|
+
if type(value) is not int:
|
|
116
|
+
raise HookError(f"{label}.{key} must be an integer")
|
|
117
|
+
if minimum is not None and value < minimum:
|
|
118
|
+
raise HookError(f"{label}.{key} must be at least {minimum}")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _validate_config_types(raw: dict[str, Any]) -> None:
|
|
122
|
+
unknown = set(raw) - ALLOWED_TOP_LEVEL_KEYS
|
|
123
|
+
if unknown:
|
|
124
|
+
raise HookError(
|
|
125
|
+
"Legacy or unsupported config keys are not allowed: " + ", ".join(sorted(unknown))
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
general = _require_table(raw.get("general", {}), "general")
|
|
129
|
+
_validate_unknown_keys(general, GENERAL_KEYS, "general")
|
|
130
|
+
for key in (
|
|
131
|
+
"enabled",
|
|
132
|
+
"allow_push_on_error",
|
|
133
|
+
"require_clean_worktree",
|
|
134
|
+
"skip_on_sync_branch",
|
|
135
|
+
):
|
|
136
|
+
_validate_bool(general, key, "general")
|
|
137
|
+
_validate_string(general, "base_branch", "general")
|
|
138
|
+
|
|
139
|
+
llm = _require_table(raw.get("llm", {}), "llm")
|
|
140
|
+
_validate_unknown_keys(llm, LLM_KEYS, "llm")
|
|
141
|
+
for key in ("runner", "model", "variant", "session_title_prefix"):
|
|
142
|
+
_validate_string(llm, key, "llm")
|
|
143
|
+
for key in ("json_retry_new_session", "delete_session_after_run"):
|
|
144
|
+
_validate_bool(llm, key, "llm")
|
|
145
|
+
_validate_integer(llm, "timeout_seconds", "llm", minimum=1)
|
|
146
|
+
_validate_integer(llm, "max_parallel", "llm", minimum=1)
|
|
147
|
+
_validate_integer(llm, "json_max_retries", "llm", minimum=0)
|
|
148
|
+
_validate_integer(llm, "invalid_json_feedback_max_chars", "llm", minimum=1)
|
|
149
|
+
_validate_integer(llm, "max_diff_bytes", "llm", minimum=1)
|
|
150
|
+
|
|
151
|
+
logging = _require_table(raw.get("logging", {}), "logging")
|
|
152
|
+
_validate_unknown_keys(logging, LOGGING_KEYS, "logging")
|
|
153
|
+
for key in ("level", "dir", "transcript_dir", "summary_dir"):
|
|
154
|
+
_validate_string(logging, key, "logging")
|
|
155
|
+
for key in ("jsonl", "capture_llm_transcript", "print_llm_output"):
|
|
156
|
+
_validate_bool(logging, key, "logging")
|
|
157
|
+
|
|
158
|
+
workflow = _require_table(raw.get("workflow", {}), "workflow")
|
|
159
|
+
_validate_unknown_keys(workflow, {"modules"}, "workflow")
|
|
160
|
+
_validate_string_list(workflow, "modules", "workflow")
|
|
161
|
+
|
|
162
|
+
modules = _require_table(raw.get("modules", {}), "modules")
|
|
163
|
+
for module_id, module_value in modules.items():
|
|
164
|
+
if not isinstance(module_id, str):
|
|
165
|
+
raise HookError("modules keys must be strings")
|
|
166
|
+
module = _require_table(module_value, f"modules.{module_id}")
|
|
167
|
+
_validate_unknown_keys(module, {"enabled", "steps"}, f"modules.{module_id}")
|
|
168
|
+
_validate_bool(module, "enabled", f"modules.{module_id}")
|
|
169
|
+
if "steps" in module:
|
|
170
|
+
steps = module["steps"]
|
|
171
|
+
if not isinstance(steps, (list, tuple)):
|
|
172
|
+
raise HookError(f"modules.{module_id}.steps must be an array of tables")
|
|
173
|
+
for index, step_value in enumerate(steps, start=1):
|
|
174
|
+
step = _require_table(step_value, f"modules.{module_id}.steps[{index}]")
|
|
175
|
+
label = f"modules.{module_id}.steps[{index}]"
|
|
176
|
+
_validate_unknown_keys(step, STEP_KEYS, label)
|
|
177
|
+
for key in ("id", "type"):
|
|
178
|
+
_validate_string(step, key, label)
|
|
179
|
+
for key in (
|
|
180
|
+
"collector",
|
|
181
|
+
"executor",
|
|
182
|
+
"assertion",
|
|
183
|
+
"output",
|
|
184
|
+
"schema",
|
|
185
|
+
"prompt",
|
|
186
|
+
"prompt_file",
|
|
187
|
+
"fallback_prompt_id",
|
|
188
|
+
"when_env",
|
|
189
|
+
):
|
|
190
|
+
_validate_string(step, key, label, allow_none=True)
|
|
191
|
+
for key in ("inputs", "allow_paths"):
|
|
192
|
+
_validate_string_list(step, key, label)
|
|
17
193
|
|
|
18
194
|
|
|
19
195
|
def _normalize_step(raw: dict[str, Any]) -> StepConfig:
|
|
@@ -41,6 +217,15 @@ def _normalize_step(raw: dict[str, Any]) -> StepConfig:
|
|
|
41
217
|
)
|
|
42
218
|
if not step.id:
|
|
43
219
|
raise HookError("Every workflow step requires a non-empty id")
|
|
220
|
+
validate_path_component(step.id, "Workflow step id")
|
|
221
|
+
if step.output:
|
|
222
|
+
validate_path_component(step.output, f"Output for step `{step.id}`")
|
|
223
|
+
for pattern in step.allow_paths:
|
|
224
|
+
parts = relative_path_parts(pattern, f"allow_paths entry for step `{step.id}`")
|
|
225
|
+
if any(normalized_component(part) == ".git" for part in parts):
|
|
226
|
+
raise HookError(f"Apply step `{step.id}` may not allow Git metadata paths")
|
|
227
|
+
if normalized_component(parts[-1]) == "agents.md":
|
|
228
|
+
raise HookError(f"Apply step `{step.id}` may not allow AGENTS.md")
|
|
44
229
|
if step.is_promptable and not any([step.prompt, step.prompt_file, step.fallback_prompt_id]):
|
|
45
230
|
raise HookError(f"Promptable step `{step.id}` requires prompt, prompt_file, or fallback_prompt_id")
|
|
46
231
|
if step.type == "collect" and not step.collector:
|
|
@@ -57,6 +242,9 @@ def _normalize_step(raw: dict[str, Any]) -> StepConfig:
|
|
|
57
242
|
|
|
58
243
|
|
|
59
244
|
def _build_config(raw: dict[str, Any]) -> HookConfig:
|
|
245
|
+
if not isinstance(raw, dict):
|
|
246
|
+
raise HookError("Config document must contain a top-level table")
|
|
247
|
+
_validate_config_types(raw)
|
|
60
248
|
unknown = set(raw) - ALLOWED_TOP_LEVEL_KEYS
|
|
61
249
|
if unknown:
|
|
62
250
|
raise HookError(
|
|
@@ -73,6 +261,7 @@ def _build_config(raw: dict[str, Any]) -> HookConfig:
|
|
|
73
261
|
|
|
74
262
|
modules: dict[str, ModuleConfig] = {}
|
|
75
263
|
for module_id in workflow_modules:
|
|
264
|
+
validate_path_component(module_id, "Workflow module id")
|
|
76
265
|
if module_id not in module_payload:
|
|
77
266
|
raise HookError(f"workflow.modules references unknown module `{module_id}`")
|
|
78
267
|
module_raw = module_payload[module_id]
|
|
@@ -88,6 +277,14 @@ def _build_config(raw: dict[str, Any]) -> HookConfig:
|
|
|
88
277
|
general = GeneralConfig(**raw.get("general", {}))
|
|
89
278
|
llm = LlmConfig(**raw.get("llm", {}))
|
|
90
279
|
logging = LoggingConfig(**raw.get("logging", {}))
|
|
280
|
+
for label, storage_path in (
|
|
281
|
+
("logging.dir", logging.dir),
|
|
282
|
+
("logging.transcript_dir", logging.transcript_dir),
|
|
283
|
+
("logging.summary_dir", logging.summary_dir),
|
|
284
|
+
):
|
|
285
|
+
parts = relative_path_parts(storage_path, label)
|
|
286
|
+
if parts[:2] != STORAGE_NAMESPACE_PARTS or len(parts) < 3:
|
|
287
|
+
raise HookError(f"{label} must be inside .git/ai-push-hooks/")
|
|
91
288
|
return HookConfig(
|
|
92
289
|
general=general,
|
|
93
290
|
llm=llm,
|
|
@@ -117,16 +314,27 @@ def _apply_env_overrides(config: HookConfig) -> HookConfig:
|
|
|
117
314
|
"steps": [step.__dict__.copy() for step in module.steps],
|
|
118
315
|
}
|
|
119
316
|
|
|
120
|
-
|
|
317
|
+
def read_env_bool(name: str) -> bool | None:
|
|
318
|
+
value = os.getenv(name)
|
|
319
|
+
if value is None:
|
|
320
|
+
return None
|
|
321
|
+
parsed = env_bool(name)
|
|
322
|
+
if parsed is None:
|
|
323
|
+
raise HookError(
|
|
324
|
+
f"Invalid boolean environment override {name}: expected true/false"
|
|
325
|
+
)
|
|
326
|
+
return parsed
|
|
327
|
+
|
|
328
|
+
skip = read_env_bool("AI_PUSH_HOOKS_SKIP")
|
|
121
329
|
if skip is True:
|
|
122
330
|
raw["general"]["enabled"] = False
|
|
123
|
-
allow_on_error =
|
|
331
|
+
allow_on_error = read_env_bool("AI_PUSH_HOOKS_ALLOW_PUSH_ON_ERROR")
|
|
124
332
|
if allow_on_error is not None:
|
|
125
333
|
raw["general"]["allow_push_on_error"] = allow_on_error
|
|
126
|
-
require_clean =
|
|
334
|
+
require_clean = read_env_bool("AI_PUSH_HOOKS_REQUIRE_CLEAN")
|
|
127
335
|
if require_clean is not None:
|
|
128
336
|
raw["general"]["require_clean_worktree"] = require_clean
|
|
129
|
-
allow_dirty =
|
|
337
|
+
allow_dirty = read_env_bool("AI_PUSH_HOOKS_ALLOW_DIRTY")
|
|
130
338
|
if allow_dirty is True:
|
|
131
339
|
raw["general"]["require_clean_worktree"] = False
|
|
132
340
|
base_branch = os.getenv("AI_PUSH_HOOKS_BASE_BRANCH")
|
|
@@ -136,7 +344,7 @@ def _apply_env_overrides(config: HookConfig) -> HookConfig:
|
|
|
136
344
|
logging_level = os.getenv("AI_PUSH_HOOKS_LOG_LEVEL")
|
|
137
345
|
if logging_level:
|
|
138
346
|
raw["logging"]["level"] = logging_level.strip().lower()
|
|
139
|
-
print_output =
|
|
347
|
+
print_output = read_env_bool("AI_PUSH_HOOKS_PRINT_LLM_OUTPUT")
|
|
140
348
|
if print_output is not None:
|
|
141
349
|
raw["logging"]["print_llm_output"] = print_output
|
|
142
350
|
model = os.getenv("AI_PUSH_HOOKS_MODEL")
|
|
@@ -146,8 +354,20 @@ def _apply_env_overrides(config: HookConfig) -> HookConfig:
|
|
|
146
354
|
if variant is not None:
|
|
147
355
|
raw["llm"]["variant"] = variant.strip()
|
|
148
356
|
timeout = os.getenv("AI_PUSH_HOOKS_TIMEOUT_SECONDS")
|
|
149
|
-
if timeout:
|
|
150
|
-
|
|
357
|
+
if timeout is not None:
|
|
358
|
+
try:
|
|
359
|
+
parsed_timeout = int(timeout.strip())
|
|
360
|
+
except ValueError as exc:
|
|
361
|
+
raise HookError(
|
|
362
|
+
"Invalid numeric environment override AI_PUSH_HOOKS_TIMEOUT_SECONDS: "
|
|
363
|
+
f"{timeout!r}"
|
|
364
|
+
) from exc
|
|
365
|
+
if parsed_timeout < 1:
|
|
366
|
+
raise HookError(
|
|
367
|
+
"Invalid numeric environment override AI_PUSH_HOOKS_TIMEOUT_SECONDS: "
|
|
368
|
+
"must be at least 1"
|
|
369
|
+
)
|
|
370
|
+
raw["llm"]["timeout_seconds"] = parsed_timeout
|
|
151
371
|
return _build_config(raw)
|
|
152
372
|
|
|
153
373
|
|
|
@@ -158,10 +378,21 @@ def load_config(repo_root: pathlib.Path) -> tuple[HookConfig, pathlib.Path]:
|
|
|
158
378
|
"Missing required config file `ai-push-hooks.toml` in repo root. "
|
|
159
379
|
"Run `ai-push-hooks init --template minimal-docs` first"
|
|
160
380
|
)
|
|
161
|
-
|
|
162
|
-
|
|
381
|
+
try:
|
|
382
|
+
text = config_path.read_text(encoding="utf-8")
|
|
383
|
+
except UnicodeDecodeError as exc:
|
|
384
|
+
raise HookError(f"Config file is not valid UTF-8: {config_path}") from exc
|
|
385
|
+
except OSError as exc:
|
|
386
|
+
raise HookError(f"Could not read config file {config_path}: {exc}") from exc
|
|
387
|
+
try:
|
|
388
|
+
loaded = tomllib.loads(text)
|
|
389
|
+
except ValueError as exc:
|
|
390
|
+
location = ""
|
|
391
|
+
if hasattr(exc, "lineno") and hasattr(exc, "colno"):
|
|
392
|
+
location = f" at line {exc.lineno}, column {exc.colno}"
|
|
393
|
+
raise HookError(f"Invalid TOML in {config_path}{location}: {exc}") from exc
|
|
163
394
|
if not isinstance(loaded, dict):
|
|
164
|
-
raise HookError(f"Invalid config format in {config_path}")
|
|
395
|
+
raise HookError(f"Invalid config format in {config_path}: expected a top-level table")
|
|
165
396
|
return _apply_env_overrides(_build_config(loaded)), config_path
|
|
166
397
|
|
|
167
398
|
|
|
@@ -169,11 +400,46 @@ def resolve_prompt_text(repo_root: pathlib.Path, step: StepConfig) -> str:
|
|
|
169
400
|
if step.prompt and step.prompt.strip():
|
|
170
401
|
return step.prompt.strip()
|
|
171
402
|
if step.prompt_file:
|
|
172
|
-
|
|
173
|
-
if
|
|
174
|
-
|
|
403
|
+
parts = relative_path_parts(step.prompt_file, f"Prompt file for step `{step.id}`")
|
|
404
|
+
if any(normalized_component(part) == ".git" for part in parts):
|
|
405
|
+
raise HookError(f"Prompt file for step `{step.id}` must not reference Git metadata")
|
|
406
|
+
lexical_prompt_path = repo_root.joinpath(*parts)
|
|
407
|
+
if path_has_symlink(repo_root, lexical_prompt_path):
|
|
408
|
+
raise HookError(f"Prompt file for step `{step.id}` must not traverse a symlink")
|
|
409
|
+
prompt_path = resolve_contained_path(
|
|
410
|
+
repo_root,
|
|
411
|
+
step.prompt_file,
|
|
412
|
+
f"Prompt file for step `{step.id}`",
|
|
413
|
+
)
|
|
414
|
+
resolved_prompt_path = prompt_path.resolve(strict=False)
|
|
415
|
+
try:
|
|
416
|
+
git_roots = (
|
|
417
|
+
resolve_git_dir(repo_root).resolve(strict=True),
|
|
418
|
+
resolve_git_common_dir(repo_root).resolve(strict=True),
|
|
419
|
+
)
|
|
420
|
+
except HookError:
|
|
421
|
+
git_roots = ()
|
|
422
|
+
if any(is_path_within(resolved_prompt_path, git_root) for git_root in git_roots):
|
|
423
|
+
raise HookError(f"Prompt file for step `{step.id}` must not resolve inside Git metadata")
|
|
175
424
|
if prompt_path.exists():
|
|
176
|
-
|
|
425
|
+
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
426
|
+
try:
|
|
427
|
+
descriptor = os.open(prompt_path, flags)
|
|
428
|
+
except OSError as exc:
|
|
429
|
+
raise HookError(
|
|
430
|
+
f"Prompt file could not be opened safely for step `{step.id}`: {prompt_path}"
|
|
431
|
+
) from exc
|
|
432
|
+
try:
|
|
433
|
+
if not stat.S_ISREG(os.fstat(descriptor).st_mode):
|
|
434
|
+
raise HookError(
|
|
435
|
+
f"Prompt file is not a regular file for step `{step.id}`: {prompt_path}"
|
|
436
|
+
)
|
|
437
|
+
with os.fdopen(descriptor, "r", encoding="utf-8") as handle:
|
|
438
|
+
descriptor = -1
|
|
439
|
+
text = handle.read().strip()
|
|
440
|
+
finally:
|
|
441
|
+
if descriptor >= 0:
|
|
442
|
+
os.close(descriptor)
|
|
177
443
|
if text:
|
|
178
444
|
return text
|
|
179
445
|
if step.fallback_prompt_id:
|
|
@@ -64,8 +64,6 @@ class WorkflowEngine:
|
|
|
64
64
|
continue
|
|
65
65
|
if any(not running_step.is_read_only for _future, (_state, running_step) in futures.items()):
|
|
66
66
|
continue
|
|
67
|
-
if not step.is_read_only and futures:
|
|
68
|
-
continue
|
|
69
67
|
if step.is_read_only and len(futures) >= max(1, self.context.config.llm.max_parallel):
|
|
70
68
|
continue
|
|
71
69
|
future = pool.submit(self._execute_step, state, step)
|