ai-push-hooks 0.1.18 → 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 +312 -41
- package/SECURITY.md +35 -0
- package/ai-push-hooks.toml +2 -1
- package/bin/ai-push-hooks.js +20 -5
- package/package.json +26 -4
- package/pyproject.toml +12 -4
- package/src/ai_push_hooks/artifacts.py +67 -11
- package/src/ai_push_hooks/cli.py +60 -3
- package/src/ai_push_hooks/config.py +284 -97
- package/src/ai_push_hooks/engine.py +0 -2
- package/src/ai_push_hooks/executors/apply.py +850 -29
- package/src/ai_push_hooks/executors/exec.py +735 -112
- package/src/ai_push_hooks/executors/llm.py +369 -42
- package/src/ai_push_hooks/hook.py +131 -21
- 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 +55 -8
- package/src/ai_push_hooks/paths.py +182 -0
- package/src/ai_push_hooks/prompts_builtin.py +7 -2
- package/src/ai_push_hooks/types.py +83 -3
|
@@ -1,102 +1,195 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import json
|
|
4
3
|
import os
|
|
5
4
|
import pathlib
|
|
6
|
-
import
|
|
5
|
+
import stat
|
|
7
6
|
from typing import Any
|
|
8
7
|
|
|
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
|
+
)
|
|
9
17
|
from .prompts_builtin import BUILTIN_PROMPTS
|
|
10
18
|
from .types import GeneralConfig, HookConfig, HookError, LlmConfig, LoggingConfig, ModuleConfig, StepConfig, SUPPORTED_STEP_TYPES, WorkflowConfig
|
|
11
|
-
from .executors.exec import env_bool
|
|
12
19
|
|
|
13
20
|
try:
|
|
14
21
|
import tomllib
|
|
15
22
|
except ModuleNotFoundError: # pragma: no cover
|
|
16
|
-
|
|
23
|
+
import tomli as tomllib
|
|
17
24
|
|
|
18
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
|
+
}
|
|
19
71
|
|
|
20
72
|
|
|
21
|
-
def
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if
|
|
61
|
-
return
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
key
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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)
|
|
100
193
|
|
|
101
194
|
|
|
102
195
|
def _normalize_step(raw: dict[str, Any]) -> StepConfig:
|
|
@@ -124,6 +217,15 @@ def _normalize_step(raw: dict[str, Any]) -> StepConfig:
|
|
|
124
217
|
)
|
|
125
218
|
if not step.id:
|
|
126
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")
|
|
127
229
|
if step.is_promptable and not any([step.prompt, step.prompt_file, step.fallback_prompt_id]):
|
|
128
230
|
raise HookError(f"Promptable step `{step.id}` requires prompt, prompt_file, or fallback_prompt_id")
|
|
129
231
|
if step.type == "collect" and not step.collector:
|
|
@@ -140,6 +242,9 @@ def _normalize_step(raw: dict[str, Any]) -> StepConfig:
|
|
|
140
242
|
|
|
141
243
|
|
|
142
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)
|
|
143
248
|
unknown = set(raw) - ALLOWED_TOP_LEVEL_KEYS
|
|
144
249
|
if unknown:
|
|
145
250
|
raise HookError(
|
|
@@ -156,6 +261,7 @@ def _build_config(raw: dict[str, Any]) -> HookConfig:
|
|
|
156
261
|
|
|
157
262
|
modules: dict[str, ModuleConfig] = {}
|
|
158
263
|
for module_id in workflow_modules:
|
|
264
|
+
validate_path_component(module_id, "Workflow module id")
|
|
159
265
|
if module_id not in module_payload:
|
|
160
266
|
raise HookError(f"workflow.modules references unknown module `{module_id}`")
|
|
161
267
|
module_raw = module_payload[module_id]
|
|
@@ -171,6 +277,14 @@ def _build_config(raw: dict[str, Any]) -> HookConfig:
|
|
|
171
277
|
general = GeneralConfig(**raw.get("general", {}))
|
|
172
278
|
llm = LlmConfig(**raw.get("llm", {}))
|
|
173
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/")
|
|
174
288
|
return HookConfig(
|
|
175
289
|
general=general,
|
|
176
290
|
llm=llm,
|
|
@@ -187,6 +301,7 @@ def _apply_env_overrides(config: HookConfig) -> HookConfig:
|
|
|
187
301
|
"allow_push_on_error": config.general.allow_push_on_error,
|
|
188
302
|
"require_clean_worktree": config.general.require_clean_worktree,
|
|
189
303
|
"skip_on_sync_branch": config.general.skip_on_sync_branch,
|
|
304
|
+
"base_branch": config.general.base_branch,
|
|
190
305
|
},
|
|
191
306
|
"llm": config.llm.__dict__.copy(),
|
|
192
307
|
"logging": config.logging.__dict__.copy(),
|
|
@@ -199,23 +314,37 @@ def _apply_env_overrides(config: HookConfig) -> HookConfig:
|
|
|
199
314
|
"steps": [step.__dict__.copy() for step in module.steps],
|
|
200
315
|
}
|
|
201
316
|
|
|
202
|
-
|
|
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")
|
|
203
329
|
if skip is True:
|
|
204
330
|
raw["general"]["enabled"] = False
|
|
205
|
-
allow_on_error =
|
|
331
|
+
allow_on_error = read_env_bool("AI_PUSH_HOOKS_ALLOW_PUSH_ON_ERROR")
|
|
206
332
|
if allow_on_error is not None:
|
|
207
333
|
raw["general"]["allow_push_on_error"] = allow_on_error
|
|
208
|
-
require_clean =
|
|
334
|
+
require_clean = read_env_bool("AI_PUSH_HOOKS_REQUIRE_CLEAN")
|
|
209
335
|
if require_clean is not None:
|
|
210
336
|
raw["general"]["require_clean_worktree"] = require_clean
|
|
211
|
-
allow_dirty =
|
|
337
|
+
allow_dirty = read_env_bool("AI_PUSH_HOOKS_ALLOW_DIRTY")
|
|
212
338
|
if allow_dirty is True:
|
|
213
339
|
raw["general"]["require_clean_worktree"] = False
|
|
340
|
+
base_branch = os.getenv("AI_PUSH_HOOKS_BASE_BRANCH")
|
|
341
|
+
if base_branch:
|
|
342
|
+
raw["general"]["base_branch"] = base_branch.strip() or "main"
|
|
214
343
|
|
|
215
344
|
logging_level = os.getenv("AI_PUSH_HOOKS_LOG_LEVEL")
|
|
216
345
|
if logging_level:
|
|
217
346
|
raw["logging"]["level"] = logging_level.strip().lower()
|
|
218
|
-
print_output =
|
|
347
|
+
print_output = read_env_bool("AI_PUSH_HOOKS_PRINT_LLM_OUTPUT")
|
|
219
348
|
if print_output is not None:
|
|
220
349
|
raw["logging"]["print_llm_output"] = print_output
|
|
221
350
|
model = os.getenv("AI_PUSH_HOOKS_MODEL")
|
|
@@ -225,8 +354,20 @@ def _apply_env_overrides(config: HookConfig) -> HookConfig:
|
|
|
225
354
|
if variant is not None:
|
|
226
355
|
raw["llm"]["variant"] = variant.strip()
|
|
227
356
|
timeout = os.getenv("AI_PUSH_HOOKS_TIMEOUT_SECONDS")
|
|
228
|
-
if timeout:
|
|
229
|
-
|
|
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
|
|
230
371
|
return _build_config(raw)
|
|
231
372
|
|
|
232
373
|
|
|
@@ -237,10 +378,21 @@ def load_config(repo_root: pathlib.Path) -> tuple[HookConfig, pathlib.Path]:
|
|
|
237
378
|
"Missing required config file `ai-push-hooks.toml` in repo root. "
|
|
238
379
|
"Run `ai-push-hooks init --template minimal-docs` first"
|
|
239
380
|
)
|
|
240
|
-
|
|
241
|
-
|
|
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
|
|
242
394
|
if not isinstance(loaded, dict):
|
|
243
|
-
raise HookError(f"Invalid config format in {config_path}")
|
|
395
|
+
raise HookError(f"Invalid config format in {config_path}: expected a top-level table")
|
|
244
396
|
return _apply_env_overrides(_build_config(loaded)), config_path
|
|
245
397
|
|
|
246
398
|
|
|
@@ -248,11 +400,46 @@ def resolve_prompt_text(repo_root: pathlib.Path, step: StepConfig) -> str:
|
|
|
248
400
|
if step.prompt and step.prompt.strip():
|
|
249
401
|
return step.prompt.strip()
|
|
250
402
|
if step.prompt_file:
|
|
251
|
-
|
|
252
|
-
if
|
|
253
|
-
|
|
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")
|
|
254
424
|
if prompt_path.exists():
|
|
255
|
-
|
|
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)
|
|
256
443
|
if text:
|
|
257
444
|
return text
|
|
258
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)
|