ai-push-hooks 0.3.1 → 0.3.2
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 +49 -1
- package/README.md +91 -80
- package/ai-push-hooks.toml +1 -1
- package/docs/configuration.md +263 -0
- package/package.json +3 -3
- package/pyproject.toml +12 -3
- package/src/ai_push_hooks/artifacts.py +19 -6
- package/src/ai_push_hooks/cli.py +24 -8
- package/src/ai_push_hooks/config.py +220 -49
- package/src/ai_push_hooks/engine.py +93 -43
- package/src/ai_push_hooks/executors/apply.py +121 -43
- package/src/ai_push_hooks/executors/ask.py +30 -13
- package/src/ai_push_hooks/executors/exec.py +61 -22
- package/src/ai_push_hooks/executors/runner_workflow.py +38 -16
- package/src/ai_push_hooks/executors/runners/claude.py +18 -6
- package/src/ai_push_hooks/executors/runners/codex.py +9 -3
- package/src/ai_push_hooks/executors/runners/command.py +25 -9
- package/src/ai_push_hooks/executors/runners/contracts.py +52 -25
- package/src/ai_push_hooks/executors/runners/opencode.py +74 -21
- package/src/ai_push_hooks/executors/runners/opencode_support.py +15 -5
- package/src/ai_push_hooks/executors/runners/process.py +26 -7
- package/src/ai_push_hooks/executors/runners/registry.py +14 -5
- package/src/ai_push_hooks/executors/step_commands.py +58 -18
- package/src/ai_push_hooks/git_utils.py +92 -27
- package/src/ai_push_hooks/hook.py +48 -12
- package/src/ai_push_hooks/install.py +40 -18
- package/src/ai_push_hooks/modules/beads.py +18 -7
- package/src/ai_push_hooks/modules/docs.py +17 -7
- package/src/ai_push_hooks/modules/pr.py +18 -7
- package/src/ai_push_hooks/paths.py +6 -2
- package/src/ai_push_hooks/plugin_loader.py +79 -26
- package/src/ai_push_hooks/plugins.py +3 -1
- package/src/ai_push_hooks/prompts_builtin.py +1 -1
- package/src/ai_push_hooks/types.py +47 -27
- package/run.sh +0 -29
|
@@ -6,7 +6,9 @@ from ..git_utils import collect_commit_messages_for_ranges, is_feature_branch
|
|
|
6
6
|
from ..types import CollectorResult, RuntimeContext
|
|
7
7
|
|
|
8
8
|
|
|
9
|
-
def collect_beads_status_context(
|
|
9
|
+
def collect_beads_status_context(
|
|
10
|
+
context: RuntimeContext, state: Any
|
|
11
|
+
) -> CollectorResult:
|
|
10
12
|
branch_name = str(context.cache.get("branch_name", ""))
|
|
11
13
|
branch_selection_reason = str(
|
|
12
14
|
context.cache.get("branch_selection_reason", "no pushed branch updates")
|
|
@@ -22,7 +24,9 @@ def collect_beads_status_context(context: RuntimeContext, state: Any) -> Collect
|
|
|
22
24
|
skip_reason=branch_selection_reason,
|
|
23
25
|
)
|
|
24
26
|
sync_branch = context.cache.get("sync_branch", "beads-sync")
|
|
25
|
-
if branch_name in {"HEAD", "main", sync_branch} or not is_feature_branch(
|
|
27
|
+
if branch_name in {"HEAD", "main", sync_branch} or not is_feature_branch(
|
|
28
|
+
branch_name
|
|
29
|
+
):
|
|
26
30
|
return CollectorResult(
|
|
27
31
|
artifacts={"branch-context.txt": f"branch={branch_name}\n"},
|
|
28
32
|
skip_module=True,
|
|
@@ -33,8 +37,12 @@ def collect_beads_status_context(context: RuntimeContext, state: Any) -> Collect
|
|
|
33
37
|
changed_files = context.cache.get(
|
|
34
38
|
"branch_changed_files", context.cache.get("changed_files", [])
|
|
35
39
|
)
|
|
36
|
-
diff_text = context.cache.get(
|
|
37
|
-
|
|
40
|
+
diff_text = context.cache.get(
|
|
41
|
+
"branch_diff_text", context.cache.get("diff_text", "")
|
|
42
|
+
)
|
|
43
|
+
commits = (
|
|
44
|
+
collect_commit_messages_for_ranges(context.repo_root, ranges) if ranges else []
|
|
45
|
+
)
|
|
38
46
|
report_file = "BEADS_STATUS_ACTION_REQUIRED.md"
|
|
39
47
|
commit_lines = []
|
|
40
48
|
for commit in commits:
|
|
@@ -54,8 +62,11 @@ def collect_beads_status_context(context: RuntimeContext, state: Any) -> Collect
|
|
|
54
62
|
]
|
|
55
63
|
)
|
|
56
64
|
+ "\n",
|
|
57
|
-
"changed-files.txt": "\n".join(changed_files)
|
|
58
|
-
|
|
59
|
-
"
|
|
65
|
+
"changed-files.txt": "\n".join(changed_files)
|
|
66
|
+
+ ("\n" if changed_files else ""),
|
|
67
|
+
"push.diff": diff_text
|
|
68
|
+
+ ("\n" if diff_text and not diff_text.endswith("\n") else ""),
|
|
69
|
+
"commits.txt": "\n".join(commit_lines).strip()
|
|
70
|
+
+ ("\n" if commit_lines else ""),
|
|
60
71
|
}
|
|
61
72
|
)
|
|
@@ -252,7 +252,9 @@ def _collect_query_matches(
|
|
|
252
252
|
return buffers, fallback_contents
|
|
253
253
|
|
|
254
254
|
|
|
255
|
-
def _search_docs_context(
|
|
255
|
+
def _search_docs_context(
|
|
256
|
+
repo_root: pathlib.Path, doc_files: list[pathlib.Path], queries: list[str]
|
|
257
|
+
) -> str:
|
|
256
258
|
repo_root = repo_root.resolve(strict=True)
|
|
257
259
|
if not doc_files:
|
|
258
260
|
return ""
|
|
@@ -297,7 +299,9 @@ def collect_docs_context(context: RuntimeContext, _state: Any) -> CollectorResul
|
|
|
297
299
|
["log", "--oneline", "-n", "20", "--", "README.md", "docs"],
|
|
298
300
|
check=False,
|
|
299
301
|
)
|
|
300
|
-
commits =
|
|
302
|
+
commits = (
|
|
303
|
+
collect_commit_messages_for_ranges(context.repo_root, ranges) if ranges else []
|
|
304
|
+
)
|
|
301
305
|
commit_lines = []
|
|
302
306
|
for commit in commits:
|
|
303
307
|
commit_lines.append(f"--- {commit['hash']}")
|
|
@@ -308,12 +312,18 @@ def collect_docs_context(context: RuntimeContext, _state: Any) -> CollectorResul
|
|
|
308
312
|
commit_lines.append("")
|
|
309
313
|
return CollectorResult(
|
|
310
314
|
artifacts={
|
|
311
|
-
"changed-files.txt": "\n".join(changed_files)
|
|
312
|
-
|
|
313
|
-
"
|
|
315
|
+
"changed-files.txt": "\n".join(changed_files)
|
|
316
|
+
+ ("\n" if changed_files else ""),
|
|
317
|
+
"push.diff": diff_text
|
|
318
|
+
+ ("\n" if diff_text and not diff_text.endswith("\n") else ""),
|
|
319
|
+
"docs-inventory.txt": "\n".join(
|
|
320
|
+
path.relative_to(context.repo_root).as_posix() for path in doc_files
|
|
321
|
+
)
|
|
314
322
|
+ ("\n" if doc_files else ""),
|
|
315
323
|
"docs-context.txt": docs_context + ("\n" if docs_context else ""),
|
|
316
|
-
"recent-commits.txt": recent_commits
|
|
317
|
-
|
|
324
|
+
"recent-commits.txt": recent_commits
|
|
325
|
+
+ ("\n" if recent_commits and not recent_commits.endswith("\n") else ""),
|
|
326
|
+
"commits.txt": "\n".join(commit_lines).strip()
|
|
327
|
+
+ ("\n" if commit_lines else ""),
|
|
318
328
|
}
|
|
319
329
|
)
|
|
@@ -37,7 +37,9 @@ def collect_pr_context(context: RuntimeContext, state: Any) -> CollectorResult:
|
|
|
37
37
|
break
|
|
38
38
|
if flag_env and env_bool(flag_env) is not True:
|
|
39
39
|
return CollectorResult(
|
|
40
|
-
artifacts={
|
|
40
|
+
artifacts={
|
|
41
|
+
"pr-context.txt": f"branch={branch_name}\nflag_env={flag_env}\n"
|
|
42
|
+
},
|
|
41
43
|
skip_module=True,
|
|
42
44
|
skip_reason="PR create env flag is not enabled",
|
|
43
45
|
)
|
|
@@ -81,7 +83,9 @@ def collect_pr_context(context: RuntimeContext, state: Any) -> CollectorResult:
|
|
|
81
83
|
existing_pr_url = ""
|
|
82
84
|
if existing_pr_url:
|
|
83
85
|
return CollectorResult(
|
|
84
|
-
artifacts={
|
|
86
|
+
artifacts={
|
|
87
|
+
"pr-context.txt": f"branch={branch_name}\nexisting_pr_url={existing_pr_url}\n"
|
|
88
|
+
},
|
|
85
89
|
skip_module=True,
|
|
86
90
|
skip_reason="open PR already exists",
|
|
87
91
|
metadata={"existing_pr_url": existing_pr_url},
|
|
@@ -91,8 +95,12 @@ def collect_pr_context(context: RuntimeContext, state: Any) -> CollectorResult:
|
|
|
91
95
|
changed_files = context.cache.get(
|
|
92
96
|
"branch_changed_files", context.cache.get("changed_files", [])
|
|
93
97
|
)
|
|
94
|
-
diff_text = context.cache.get(
|
|
95
|
-
|
|
98
|
+
diff_text = context.cache.get(
|
|
99
|
+
"branch_diff_text", context.cache.get("diff_text", "")
|
|
100
|
+
)
|
|
101
|
+
commits = (
|
|
102
|
+
collect_commit_messages_for_ranges(context.repo_root, ranges) if ranges else []
|
|
103
|
+
)
|
|
96
104
|
commit_lines = []
|
|
97
105
|
for commit in commits:
|
|
98
106
|
commit_lines.append(f"--- {commit['hash']}")
|
|
@@ -113,8 +121,11 @@ def collect_pr_context(context: RuntimeContext, state: Any) -> CollectorResult:
|
|
|
113
121
|
]
|
|
114
122
|
)
|
|
115
123
|
+ "\n",
|
|
116
|
-
"changed-files.txt": "\n".join(changed_files)
|
|
117
|
-
|
|
118
|
-
"
|
|
124
|
+
"changed-files.txt": "\n".join(changed_files)
|
|
125
|
+
+ ("\n" if changed_files else ""),
|
|
126
|
+
"push.diff": diff_text
|
|
127
|
+
+ ("\n" if diff_text and not diff_text.endswith("\n") else ""),
|
|
128
|
+
"commits.txt": "\n".join(commit_lines).strip()
|
|
129
|
+
+ ("\n" if commit_lines else ""),
|
|
119
130
|
}
|
|
120
131
|
)
|
|
@@ -98,7 +98,9 @@ def resolve_contained_path(base: pathlib.Path, raw: str, label: str) -> pathlib.
|
|
|
98
98
|
resolved_base = lexical_base.resolve(strict=False)
|
|
99
99
|
resolved_candidate = lexical_candidate.resolve(strict=False)
|
|
100
100
|
if not is_path_within(resolved_candidate, resolved_base):
|
|
101
|
-
raise HookError(
|
|
101
|
+
raise HookError(
|
|
102
|
+
f"{label} escapes its intended directory through a symlink: {raw}"
|
|
103
|
+
)
|
|
102
104
|
return resolved_candidate
|
|
103
105
|
|
|
104
106
|
|
|
@@ -178,5 +180,7 @@ def atomic_write_bytes(
|
|
|
178
180
|
pass
|
|
179
181
|
|
|
180
182
|
|
|
181
|
-
def write_text_no_follow(
|
|
183
|
+
def write_text_no_follow(
|
|
184
|
+
path: pathlib.Path, content: str, *, encoding: str = "utf-8"
|
|
185
|
+
) -> None:
|
|
182
186
|
atomic_write_bytes(path, content.encode(encoding))
|
|
@@ -33,6 +33,8 @@ from .types import HookError, ModuleRuntimeState, RuntimeContext, StepConfig
|
|
|
33
33
|
|
|
34
34
|
_CALLABLE_PATTERN = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\Z")
|
|
35
35
|
_SOURCE_ENCODING = "utf-8"
|
|
36
|
+
PLUGIN_SOURCE_MAX_BYTES = 1 * 1024 * 1024
|
|
37
|
+
_SOURCE_READ_CHUNK_BYTES = 64 * 1024
|
|
36
38
|
_OS_OPEN = os.open
|
|
37
39
|
_DESCRIPTOR_RELATIVE_SUPPORTED = bool(
|
|
38
40
|
getattr(os, "O_DIRECTORY", 0)
|
|
@@ -45,6 +47,31 @@ def _descriptor_relative_supported() -> bool:
|
|
|
45
47
|
return _DESCRIPTOR_RELATIVE_SUPPORTED
|
|
46
48
|
|
|
47
49
|
|
|
50
|
+
def _read_source_limited(descriptor: int) -> bytes:
|
|
51
|
+
"""Read plugin source from a checked descriptor within the source budget."""
|
|
52
|
+
|
|
53
|
+
oversize_message = (
|
|
54
|
+
f"Python plugin source exceeds maximum size of {PLUGIN_SOURCE_MAX_BYTES} bytes"
|
|
55
|
+
)
|
|
56
|
+
if os.fstat(descriptor).st_size > PLUGIN_SOURCE_MAX_BYTES:
|
|
57
|
+
raise HookError(oversize_message)
|
|
58
|
+
content = bytearray()
|
|
59
|
+
while True:
|
|
60
|
+
read_limit = min(
|
|
61
|
+
_SOURCE_READ_CHUNK_BYTES,
|
|
62
|
+
PLUGIN_SOURCE_MAX_BYTES - len(content) + 1,
|
|
63
|
+
)
|
|
64
|
+
try:
|
|
65
|
+
chunk = os.read(descriptor, max(1, read_limit))
|
|
66
|
+
except OSError:
|
|
67
|
+
raise HookError("Python plugin source could not be read") from None
|
|
68
|
+
if not chunk:
|
|
69
|
+
return bytes(content)
|
|
70
|
+
content.extend(chunk)
|
|
71
|
+
if len(content) > PLUGIN_SOURCE_MAX_BYTES:
|
|
72
|
+
raise HookError(oversize_message)
|
|
73
|
+
|
|
74
|
+
|
|
48
75
|
def _reference_parts(reference: str) -> tuple[str, str]:
|
|
49
76
|
if not isinstance(reference, str) or reference.count(":") != 1:
|
|
50
77
|
raise HookError(
|
|
@@ -82,7 +109,12 @@ def _open_source_descriptor_relative(
|
|
|
82
109
|
| getattr(os, "O_DIRECTORY", 0)
|
|
83
110
|
| getattr(os, "O_NOFOLLOW", 0)
|
|
84
111
|
)
|
|
85
|
-
file_flags =
|
|
112
|
+
file_flags = (
|
|
113
|
+
os.O_RDONLY
|
|
114
|
+
| getattr(os, "O_CLOEXEC", 0)
|
|
115
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
116
|
+
| getattr(os, "O_NONBLOCK", 0)
|
|
117
|
+
)
|
|
86
118
|
try:
|
|
87
119
|
directory_fd = _OS_OPEN(root, directory_flags)
|
|
88
120
|
for part in parts[:-1]:
|
|
@@ -92,17 +124,21 @@ def _open_source_descriptor_relative(
|
|
|
92
124
|
file_fd = _OS_OPEN(parts[-1], file_flags, dir_fd=directory_fd)
|
|
93
125
|
metadata = os.fstat(file_fd)
|
|
94
126
|
if not stat.S_ISREG(metadata.st_mode):
|
|
95
|
-
raise HookError(
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
127
|
+
raise HookError(
|
|
128
|
+
"Python plugin path must reference an ordinary regular file"
|
|
129
|
+
)
|
|
130
|
+
return root.joinpath(*parts), _read_source_limited(file_fd)
|
|
99
131
|
except HookError:
|
|
100
132
|
raise
|
|
101
133
|
except OSError as exc:
|
|
102
134
|
if exc.errno in {errno.ELOOP, errno.ENOTDIR}:
|
|
103
|
-
raise HookError(
|
|
135
|
+
raise HookError(
|
|
136
|
+
"Python plugin path must not traverse a symlink or reparse point"
|
|
137
|
+
) from None
|
|
104
138
|
if exc.errno == errno.ENOENT:
|
|
105
|
-
raise HookError(
|
|
139
|
+
raise HookError(
|
|
140
|
+
"Python plugin path must reference an existing regular file"
|
|
141
|
+
) from None
|
|
106
142
|
raise HookError("Python plugin path could not be opened safely") from None
|
|
107
143
|
finally:
|
|
108
144
|
if file_fd >= 0:
|
|
@@ -111,18 +147,24 @@ def _open_source_descriptor_relative(
|
|
|
111
147
|
os.close(directory_fd)
|
|
112
148
|
|
|
113
149
|
|
|
114
|
-
def _open_source_absolute(
|
|
150
|
+
def _open_source_absolute(
|
|
151
|
+
root: pathlib.Path, relative_path: str
|
|
152
|
+
) -> tuple[pathlib.Path, bytes]:
|
|
115
153
|
"""Fallback for platforms without descriptor-relative open support."""
|
|
116
154
|
|
|
117
155
|
lexical_path = root.joinpath(*relative_path.split("/"))
|
|
118
156
|
if path_has_symlink(root, lexical_path):
|
|
119
|
-
raise HookError(
|
|
157
|
+
raise HookError(
|
|
158
|
+
"Python plugin path must not traverse a symlink or reparse point"
|
|
159
|
+
)
|
|
120
160
|
callback_path = resolve_contained_path(root, relative_path, "Python plugin path")
|
|
121
161
|
|
|
122
162
|
try:
|
|
123
163
|
metadata = callback_path.lstat()
|
|
124
164
|
except (FileNotFoundError, OSError):
|
|
125
|
-
raise HookError(
|
|
165
|
+
raise HookError(
|
|
166
|
+
"Python plugin path must reference an existing regular file"
|
|
167
|
+
) from None
|
|
126
168
|
reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
|
|
127
169
|
if stat.S_ISLNK(metadata.st_mode) or bool(
|
|
128
170
|
getattr(metadata, "st_file_attributes", 0) & reparse_flag
|
|
@@ -131,7 +173,12 @@ def _open_source_absolute(root: pathlib.Path, relative_path: str) -> tuple[pathl
|
|
|
131
173
|
if not stat.S_ISREG(metadata.st_mode):
|
|
132
174
|
raise HookError("Python plugin path must reference an ordinary regular file")
|
|
133
175
|
|
|
134
|
-
flags =
|
|
176
|
+
flags = (
|
|
177
|
+
os.O_RDONLY
|
|
178
|
+
| getattr(os, "O_CLOEXEC", 0)
|
|
179
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
180
|
+
| getattr(os, "O_NONBLOCK", 0)
|
|
181
|
+
)
|
|
135
182
|
try:
|
|
136
183
|
descriptor = os.open(callback_path, flags)
|
|
137
184
|
except OSError:
|
|
@@ -139,10 +186,10 @@ def _open_source_absolute(root: pathlib.Path, relative_path: str) -> tuple[pathl
|
|
|
139
186
|
try:
|
|
140
187
|
descriptor_metadata = os.fstat(descriptor)
|
|
141
188
|
if not stat.S_ISREG(descriptor_metadata.st_mode):
|
|
142
|
-
raise HookError(
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
189
|
+
raise HookError(
|
|
190
|
+
"Python plugin path must reference an ordinary regular file"
|
|
191
|
+
)
|
|
192
|
+
return callback_path, _read_source_limited(descriptor)
|
|
146
193
|
except HookError:
|
|
147
194
|
raise
|
|
148
195
|
except OSError:
|
|
@@ -161,10 +208,10 @@ def _open_source(root: pathlib.Path, relative_path: str) -> tuple[pathlib.Path,
|
|
|
161
208
|
return _open_source_absolute(root, relative_path)
|
|
162
209
|
|
|
163
210
|
|
|
164
|
-
def _failure(
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
)
|
|
211
|
+
def _failure(
|
|
212
|
+
stage: str, relative_path: str, callable_name: str, detail: str
|
|
213
|
+
) -> HookError:
|
|
214
|
+
return HookError(f"Python {stage} plugin {relative_path}:{callable_name} {detail}")
|
|
168
215
|
|
|
169
216
|
|
|
170
217
|
class PluginLoader:
|
|
@@ -176,9 +223,7 @@ class PluginLoader:
|
|
|
176
223
|
self._source_locks: dict[tuple[pathlib.Path, pathlib.Path], threading.Lock] = {}
|
|
177
224
|
self._module_number = 0
|
|
178
225
|
|
|
179
|
-
def _source_lock(
|
|
180
|
-
self, key: tuple[pathlib.Path, pathlib.Path]
|
|
181
|
-
) -> threading.Lock:
|
|
226
|
+
def _source_lock(self, key: tuple[pathlib.Path, pathlib.Path]) -> threading.Lock:
|
|
182
227
|
with self._lock:
|
|
183
228
|
lock = self._source_locks.get(key)
|
|
184
229
|
if lock is None:
|
|
@@ -321,13 +366,17 @@ class PluginLoader:
|
|
|
321
366
|
except SystemExit:
|
|
322
367
|
raise _failure(stage, relative_path, callable_name, "exited") from None
|
|
323
368
|
except Exception: # noqa: BLE001
|
|
324
|
-
raise _failure(
|
|
369
|
+
raise _failure(
|
|
370
|
+
stage, relative_path, callable_name, "raised an exception"
|
|
371
|
+
) from None
|
|
325
372
|
|
|
326
373
|
if inspect.isawaitable(value):
|
|
327
374
|
close = getattr(value, "close", None)
|
|
328
375
|
if callable(close):
|
|
329
376
|
close()
|
|
330
|
-
raise _failure(
|
|
377
|
+
raise _failure(
|
|
378
|
+
stage, relative_path, callable_name, "must return synchronously"
|
|
379
|
+
)
|
|
331
380
|
return value
|
|
332
381
|
|
|
333
382
|
# Explicit aliases make the intended internal seam easy to integrate while
|
|
@@ -344,7 +393,9 @@ def _ordered_inputs(
|
|
|
344
393
|
extra = [reference for reference in input_paths if reference not in declared]
|
|
345
394
|
if extra:
|
|
346
395
|
raise HookError(f"Undeclared Python plugin input: {extra[0]}")
|
|
347
|
-
missing = [
|
|
396
|
+
missing = [
|
|
397
|
+
reference for reference in step.inputs if reference not in input_paths
|
|
398
|
+
]
|
|
348
399
|
if missing:
|
|
349
400
|
raise HookError(f"Missing resolved Python plugin input: {missing[0]}")
|
|
350
401
|
return {reference: input_paths[reference] for reference in step.inputs}
|
|
@@ -403,7 +454,9 @@ class PluginDispatcher:
|
|
|
403
454
|
input_paths: Mapping[str, pathlib.Path] | Sequence[pathlib.Path],
|
|
404
455
|
) -> Any:
|
|
405
456
|
if not step.python:
|
|
406
|
-
raise HookError(
|
|
457
|
+
raise HookError(
|
|
458
|
+
f"Python {step.type} step `{step.id}` has no callback reference"
|
|
459
|
+
)
|
|
407
460
|
context = build_plugin_context(runtime, state, step, input_paths)
|
|
408
461
|
return self.loader.invoke(
|
|
409
462
|
runtime.repo_root,
|
|
@@ -67,7 +67,9 @@ class PluginContext:
|
|
|
67
67
|
def __post_init__(self) -> None:
|
|
68
68
|
object.__setattr__(self, "inputs", MappingProxyType(dict(self.inputs)))
|
|
69
69
|
object.__setattr__(self, "options", _freeze(self.options))
|
|
70
|
-
object.__setattr__(
|
|
70
|
+
object.__setattr__(
|
|
71
|
+
self, "prior_module_metadata", _freeze(self.prior_module_metadata)
|
|
72
|
+
)
|
|
71
73
|
|
|
72
74
|
|
|
73
75
|
def _require_json(value: Any, label: str) -> None:
|
|
@@ -77,7 +77,7 @@ base_branch = "main"
|
|
|
77
77
|
# Repository callbacks use python = "path/to/file.py:callable" and command
|
|
78
78
|
# steps use a direct argv array; both are trusted local code.
|
|
79
79
|
runner = "opencode"
|
|
80
|
-
model = "openai/gpt-5.6-
|
|
80
|
+
model = "openai/gpt-5.6-luna"
|
|
81
81
|
variant = ""
|
|
82
82
|
timeout_seconds = 800
|
|
83
83
|
max_parallel = 2
|
|
@@ -81,7 +81,7 @@ class GeneralConfig:
|
|
|
81
81
|
@dataclass(frozen=True)
|
|
82
82
|
class LlmConfig:
|
|
83
83
|
runner: str = "opencode"
|
|
84
|
-
model: str = "openai/gpt-5.6-
|
|
84
|
+
model: str = "openai/gpt-5.6-luna"
|
|
85
85
|
variant: str = ""
|
|
86
86
|
timeout_seconds: int = 800
|
|
87
87
|
max_parallel: int = 2
|
|
@@ -178,10 +178,7 @@ class CollectorResult:
|
|
|
178
178
|
|
|
179
179
|
@dataclass
|
|
180
180
|
class StepResult:
|
|
181
|
-
status: str = "completed"
|
|
182
|
-
artifacts: dict[str, pathlib.Path] = field(default_factory=dict)
|
|
183
181
|
metadata: dict[str, Any] = field(default_factory=dict)
|
|
184
|
-
message: str = ""
|
|
185
182
|
|
|
186
183
|
|
|
187
184
|
@dataclass
|
|
@@ -328,7 +325,9 @@ class HookLogger:
|
|
|
328
325
|
call_number = cls._safe_text(fields.get("call_number", ""))
|
|
329
326
|
stage = cls._stage_for_console(fields.get("stage_name", ""), colors_enabled)
|
|
330
327
|
purpose = cls._style(
|
|
331
|
-
cls._safe_text(fields.get("purpose", ""))
|
|
328
|
+
cls._safe_text(fields.get("purpose", ""))
|
|
329
|
+
.replace("\n", "\\n")
|
|
330
|
+
.replace("\t", " "),
|
|
332
331
|
"34",
|
|
333
332
|
colors_enabled,
|
|
334
333
|
)
|
|
@@ -351,12 +350,16 @@ class HookLogger:
|
|
|
351
350
|
call_number = cls._safe_text(fields.get("call_number", ""))
|
|
352
351
|
stage = cls._stage_for_console(fields.get("stage_name", ""), colors_enabled)
|
|
353
352
|
profile = cls._style(
|
|
354
|
-
cls._safe_text(fields.get("runner_profile", ""))
|
|
353
|
+
cls._safe_text(fields.get("runner_profile", ""))
|
|
354
|
+
.replace("\n", "\\n")
|
|
355
|
+
.replace("\t", " "),
|
|
355
356
|
"34",
|
|
356
357
|
colors_enabled,
|
|
357
358
|
)
|
|
358
359
|
runner_type = cls._style(
|
|
359
|
-
cls._safe_text(fields.get("runner_type", ""))
|
|
360
|
+
cls._safe_text(fields.get("runner_type", ""))
|
|
361
|
+
.replace("\n", "\\n")
|
|
362
|
+
.replace("\t", " "),
|
|
360
363
|
"34",
|
|
361
364
|
colors_enabled,
|
|
362
365
|
)
|
|
@@ -377,7 +380,9 @@ class HookLogger:
|
|
|
377
380
|
+ cls._style(")", "2", colors_enabled)
|
|
378
381
|
)
|
|
379
382
|
if "; " in safe_message:
|
|
380
|
-
body += cls._style(
|
|
383
|
+
body += cls._style(
|
|
384
|
+
"; " + safe_message.split("; ", 1)[1], "2", colors_enabled
|
|
385
|
+
)
|
|
381
386
|
return body
|
|
382
387
|
|
|
383
388
|
return safe_message
|
|
@@ -448,13 +453,16 @@ class HookLogger:
|
|
|
448
453
|
if initial_metadata is not None:
|
|
449
454
|
reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
|
|
450
455
|
if stat.S_ISLNK(initial_metadata.st_mode) or bool(
|
|
451
|
-
getattr(initial_metadata, "st_file_attributes", 0)
|
|
456
|
+
getattr(initial_metadata, "st_file_attributes", 0)
|
|
457
|
+
& reparse_flag
|
|
452
458
|
):
|
|
453
459
|
raise HookError(
|
|
454
460
|
"JSONL log target must not be a symlink or reparse point: "
|
|
455
461
|
f"{self.jsonl_path}"
|
|
456
462
|
)
|
|
457
|
-
flags =
|
|
463
|
+
flags = (
|
|
464
|
+
os.O_WRONLY | os.O_APPEND | os.O_CREAT | getattr(os, "O_CLOEXEC", 0)
|
|
465
|
+
)
|
|
458
466
|
flags |= getattr(os, "O_NOFOLLOW", 0)
|
|
459
467
|
descriptor = os.open(self.jsonl_path, flags, 0o600)
|
|
460
468
|
try:
|
|
@@ -465,12 +473,15 @@ class HookLogger:
|
|
|
465
473
|
not stat.S_ISREG(descriptor_metadata.st_mode)
|
|
466
474
|
or stat.S_ISLNK(path_metadata.st_mode)
|
|
467
475
|
or bool(
|
|
468
|
-
getattr(path_metadata, "st_file_attributes", 0)
|
|
476
|
+
getattr(path_metadata, "st_file_attributes", 0)
|
|
477
|
+
& reparse_flag
|
|
469
478
|
)
|
|
470
479
|
or (descriptor_metadata.st_dev, descriptor_metadata.st_ino)
|
|
471
480
|
!= (path_metadata.st_dev, path_metadata.st_ino)
|
|
472
481
|
):
|
|
473
|
-
raise HookError(
|
|
482
|
+
raise HookError(
|
|
483
|
+
f"JSONL log target is not a regular file: {self.jsonl_path}"
|
|
484
|
+
)
|
|
474
485
|
os.fchmod(descriptor, 0o600)
|
|
475
486
|
os.write(
|
|
476
487
|
descriptor,
|
|
@@ -522,7 +533,9 @@ class HookLogger:
|
|
|
522
533
|
"purpose": safe_purpose,
|
|
523
534
|
"model": self._safe_text(model),
|
|
524
535
|
"module": safe_stage.split(".", 1)[0],
|
|
525
|
-
"step": safe_stage.split(".", 1)[1]
|
|
536
|
+
"step": safe_stage.split(".", 1)[1]
|
|
537
|
+
if "." in safe_stage
|
|
538
|
+
else safe_stage,
|
|
526
539
|
}
|
|
527
540
|
if attempt is not None:
|
|
528
541
|
record["attempt"] = attempt
|
|
@@ -559,16 +572,20 @@ class HookLogger:
|
|
|
559
572
|
safe_stage = self._safe_text(stage_name)
|
|
560
573
|
safe_profile = self._safe_text(runner_profile)
|
|
561
574
|
safe_type = self._safe_text(runner_type)
|
|
562
|
-
safe_state =
|
|
563
|
-
|
|
564
|
-
|
|
575
|
+
safe_state = (
|
|
576
|
+
self._safe_text(session_state) if session_state is not None else None
|
|
577
|
+
)
|
|
578
|
+
safe_session_id = (
|
|
579
|
+
self._safe_text(session_id) if session_id is not None else None
|
|
580
|
+
)
|
|
581
|
+
safe_transcript = (
|
|
582
|
+
self._safe_text(transcript) if transcript is not None else None
|
|
583
|
+
)
|
|
565
584
|
safe_resume_command = (
|
|
566
585
|
self._safe_text(resume_command) if resume_command is not None else None
|
|
567
586
|
)
|
|
568
587
|
effective_resume_command = (
|
|
569
|
-
safe_resume_command
|
|
570
|
-
if safe_state == "persisted" and resumable
|
|
571
|
-
else None
|
|
588
|
+
safe_resume_command if safe_state == "persisted" and resumable else None
|
|
572
589
|
)
|
|
573
590
|
session_details: list[str] = []
|
|
574
591
|
if safe_state == "persisted":
|
|
@@ -622,15 +639,18 @@ class HookLogger:
|
|
|
622
639
|
fields["transcript"] = safe_transcript
|
|
623
640
|
if effective_resume_command is not None:
|
|
624
641
|
fields["resume_command"] = effective_resume_command
|
|
625
|
-
if
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
642
|
+
if (
|
|
643
|
+
any(
|
|
644
|
+
value is not None
|
|
645
|
+
for value in (
|
|
646
|
+
safe_session_id,
|
|
647
|
+
safe_state,
|
|
648
|
+
safe_transcript,
|
|
649
|
+
effective_resume_command,
|
|
650
|
+
)
|
|
632
651
|
)
|
|
633
|
-
|
|
652
|
+
or resumable
|
|
653
|
+
):
|
|
634
654
|
fields["resumable"] = resumable
|
|
635
655
|
self.status("llm.complete", message, **fields)
|
|
636
656
|
|
package/run.sh
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
|
|
3
|
-
set -euo pipefail
|
|
4
|
-
|
|
5
|
-
if [[ "${AI_PUSH_HOOKS_SKIP:-0}" == "1" ]]; then
|
|
6
|
-
printf '[ai-push-hooks] Skipped (AI_PUSH_HOOKS_SKIP=1).\n' >&2
|
|
7
|
-
exit 0
|
|
8
|
-
fi
|
|
9
|
-
|
|
10
|
-
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
11
|
-
|
|
12
|
-
if command -v python3 >/dev/null 2>&1; then
|
|
13
|
-
py_cmd="python3"
|
|
14
|
-
elif command -v python >/dev/null 2>&1; then
|
|
15
|
-
py_cmd="python"
|
|
16
|
-
else
|
|
17
|
-
printf '[ai-push-hooks] python3/python is required but not installed.\n' >&2
|
|
18
|
-
exit 1
|
|
19
|
-
fi
|
|
20
|
-
|
|
21
|
-
if [[ -d "${script_dir}/src" ]]; then
|
|
22
|
-
if [[ -n "${PYTHONPATH:-}" ]]; then
|
|
23
|
-
export PYTHONPATH="${script_dir}/src:${PYTHONPATH}"
|
|
24
|
-
else
|
|
25
|
-
export PYTHONPATH="${script_dir}/src"
|
|
26
|
-
fi
|
|
27
|
-
fi
|
|
28
|
-
|
|
29
|
-
exec "${py_cmd}" -m ai_push_hooks "$@"
|