ai-push-hooks 0.3.0 → 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 +78 -2
- package/README.md +80 -993
- package/SECURITY.md +21 -16
- package/ai-push-hooks.toml +1 -1
- package/bin/ai-push-hooks.js +6 -6
- package/docs/configuration.md +263 -0
- package/package.json +4 -3
- package/pyproject.toml +12 -3
- package/src/ai_push_hooks/artifacts.py +19 -19
- package/src/ai_push_hooks/cli.py +24 -8
- package/src/ai_push_hooks/config.py +239 -54
- package/src/ai_push_hooks/engine.py +94 -43
- package/src/ai_push_hooks/executors/apply.py +124 -46
- package/src/ai_push_hooks/executors/ask.py +30 -536
- package/src/ai_push_hooks/executors/exec.py +73 -818
- package/src/ai_push_hooks/executors/runner_workflow.py +42 -20
- 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 +109 -27
- package/src/ai_push_hooks/executors/runners/opencode_support.py +286 -0
- package/src/ai_push_hooks/executors/runners/process.py +97 -17
- package/src/ai_push_hooks/executors/runners/registry.py +31 -9
- package/src/ai_push_hooks/executors/step_commands.py +65 -20
- package/src/ai_push_hooks/git_utils.py +899 -0
- package/src/ai_push_hooks/hook.py +49 -13
- package/src/ai_push_hooks/install.py +40 -18
- package/src/ai_push_hooks/modules/beads.py +19 -8
- package/src/ai_push_hooks/modules/docs.py +146 -96
- package/src/ai_push_hooks/modules/pr.py +19 -8
- package/src/ai_push_hooks/paths.py +6 -2
- package/src/ai_push_hooks/plugin_loader.py +182 -105
- 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 +48 -27
- package/vendor/README.md +15 -0
- package/vendor/requirements.txt +1 -0
- package/vendor/tomli-2.4.0-py3-none-any.whl +0 -0
- package/run.sh +0 -29
|
@@ -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))
|
|
@@ -10,7 +10,6 @@ completed.
|
|
|
10
10
|
|
|
11
11
|
from __future__ import annotations # noqa: I001
|
|
12
12
|
|
|
13
|
-
import copy
|
|
14
13
|
import errno
|
|
15
14
|
import hashlib
|
|
16
15
|
import inspect
|
|
@@ -34,6 +33,8 @@ from .types import HookError, ModuleRuntimeState, RuntimeContext, StepConfig
|
|
|
34
33
|
|
|
35
34
|
_CALLABLE_PATTERN = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\Z")
|
|
36
35
|
_SOURCE_ENCODING = "utf-8"
|
|
36
|
+
PLUGIN_SOURCE_MAX_BYTES = 1 * 1024 * 1024
|
|
37
|
+
_SOURCE_READ_CHUNK_BYTES = 64 * 1024
|
|
37
38
|
_OS_OPEN = os.open
|
|
38
39
|
_DESCRIPTOR_RELATIVE_SUPPORTED = bool(
|
|
39
40
|
getattr(os, "O_DIRECTORY", 0)
|
|
@@ -46,6 +47,31 @@ def _descriptor_relative_supported() -> bool:
|
|
|
46
47
|
return _DESCRIPTOR_RELATIVE_SUPPORTED
|
|
47
48
|
|
|
48
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
|
+
|
|
49
75
|
def _reference_parts(reference: str) -> tuple[str, str]:
|
|
50
76
|
if not isinstance(reference, str) or reference.count(":") != 1:
|
|
51
77
|
raise HookError(
|
|
@@ -83,7 +109,12 @@ def _open_source_descriptor_relative(
|
|
|
83
109
|
| getattr(os, "O_DIRECTORY", 0)
|
|
84
110
|
| getattr(os, "O_NOFOLLOW", 0)
|
|
85
111
|
)
|
|
86
|
-
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
|
+
)
|
|
87
118
|
try:
|
|
88
119
|
directory_fd = _OS_OPEN(root, directory_flags)
|
|
89
120
|
for part in parts[:-1]:
|
|
@@ -93,17 +124,21 @@ def _open_source_descriptor_relative(
|
|
|
93
124
|
file_fd = _OS_OPEN(parts[-1], file_flags, dir_fd=directory_fd)
|
|
94
125
|
metadata = os.fstat(file_fd)
|
|
95
126
|
if not stat.S_ISREG(metadata.st_mode):
|
|
96
|
-
raise HookError(
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
127
|
+
raise HookError(
|
|
128
|
+
"Python plugin path must reference an ordinary regular file"
|
|
129
|
+
)
|
|
130
|
+
return root.joinpath(*parts), _read_source_limited(file_fd)
|
|
100
131
|
except HookError:
|
|
101
132
|
raise
|
|
102
133
|
except OSError as exc:
|
|
103
134
|
if exc.errno in {errno.ELOOP, errno.ENOTDIR}:
|
|
104
|
-
raise HookError(
|
|
135
|
+
raise HookError(
|
|
136
|
+
"Python plugin path must not traverse a symlink or reparse point"
|
|
137
|
+
) from None
|
|
105
138
|
if exc.errno == errno.ENOENT:
|
|
106
|
-
raise HookError(
|
|
139
|
+
raise HookError(
|
|
140
|
+
"Python plugin path must reference an existing regular file"
|
|
141
|
+
) from None
|
|
107
142
|
raise HookError("Python plugin path could not be opened safely") from None
|
|
108
143
|
finally:
|
|
109
144
|
if file_fd >= 0:
|
|
@@ -112,18 +147,24 @@ def _open_source_descriptor_relative(
|
|
|
112
147
|
os.close(directory_fd)
|
|
113
148
|
|
|
114
149
|
|
|
115
|
-
def _open_source_absolute(
|
|
150
|
+
def _open_source_absolute(
|
|
151
|
+
root: pathlib.Path, relative_path: str
|
|
152
|
+
) -> tuple[pathlib.Path, bytes]:
|
|
116
153
|
"""Fallback for platforms without descriptor-relative open support."""
|
|
117
154
|
|
|
118
155
|
lexical_path = root.joinpath(*relative_path.split("/"))
|
|
119
156
|
if path_has_symlink(root, lexical_path):
|
|
120
|
-
raise HookError(
|
|
157
|
+
raise HookError(
|
|
158
|
+
"Python plugin path must not traverse a symlink or reparse point"
|
|
159
|
+
)
|
|
121
160
|
callback_path = resolve_contained_path(root, relative_path, "Python plugin path")
|
|
122
161
|
|
|
123
162
|
try:
|
|
124
163
|
metadata = callback_path.lstat()
|
|
125
164
|
except (FileNotFoundError, OSError):
|
|
126
|
-
raise HookError(
|
|
165
|
+
raise HookError(
|
|
166
|
+
"Python plugin path must reference an existing regular file"
|
|
167
|
+
) from None
|
|
127
168
|
reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
|
|
128
169
|
if stat.S_ISLNK(metadata.st_mode) or bool(
|
|
129
170
|
getattr(metadata, "st_file_attributes", 0) & reparse_flag
|
|
@@ -132,7 +173,12 @@ def _open_source_absolute(root: pathlib.Path, relative_path: str) -> tuple[pathl
|
|
|
132
173
|
if not stat.S_ISREG(metadata.st_mode):
|
|
133
174
|
raise HookError("Python plugin path must reference an ordinary regular file")
|
|
134
175
|
|
|
135
|
-
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
|
+
)
|
|
136
182
|
try:
|
|
137
183
|
descriptor = os.open(callback_path, flags)
|
|
138
184
|
except OSError:
|
|
@@ -140,10 +186,10 @@ def _open_source_absolute(root: pathlib.Path, relative_path: str) -> tuple[pathl
|
|
|
140
186
|
try:
|
|
141
187
|
descriptor_metadata = os.fstat(descriptor)
|
|
142
188
|
if not stat.S_ISREG(descriptor_metadata.st_mode):
|
|
143
|
-
raise HookError(
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
189
|
+
raise HookError(
|
|
190
|
+
"Python plugin path must reference an ordinary regular file"
|
|
191
|
+
)
|
|
192
|
+
return callback_path, _read_source_limited(descriptor)
|
|
147
193
|
except HookError:
|
|
148
194
|
raise
|
|
149
195
|
except OSError:
|
|
@@ -162,10 +208,10 @@ def _open_source(root: pathlib.Path, relative_path: str) -> tuple[pathlib.Path,
|
|
|
162
208
|
return _open_source_absolute(root, relative_path)
|
|
163
209
|
|
|
164
210
|
|
|
165
|
-
def _failure(
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
)
|
|
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}")
|
|
169
215
|
|
|
170
216
|
|
|
171
217
|
class PluginLoader:
|
|
@@ -174,96 +220,119 @@ class PluginLoader:
|
|
|
174
220
|
def __init__(self) -> None:
|
|
175
221
|
self._cache: dict[tuple[pathlib.Path, pathlib.Path], ModuleType] = {}
|
|
176
222
|
self._lock = threading.RLock()
|
|
223
|
+
self._source_locks: dict[tuple[pathlib.Path, pathlib.Path], threading.Lock] = {}
|
|
177
224
|
self._module_number = 0
|
|
178
225
|
|
|
226
|
+
def _source_lock(self, key: tuple[pathlib.Path, pathlib.Path]) -> threading.Lock:
|
|
227
|
+
with self._lock:
|
|
228
|
+
lock = self._source_locks.get(key)
|
|
229
|
+
if lock is None:
|
|
230
|
+
lock = threading.Lock()
|
|
231
|
+
self._source_locks[key] = lock
|
|
232
|
+
return lock
|
|
233
|
+
|
|
234
|
+
def _load_module(
|
|
235
|
+
self,
|
|
236
|
+
root: pathlib.Path,
|
|
237
|
+
relative_path: str,
|
|
238
|
+
callable_name: str,
|
|
239
|
+
) -> ModuleType:
|
|
240
|
+
try:
|
|
241
|
+
callback_path, source = _open_source(root, relative_path)
|
|
242
|
+
except HookError as exc:
|
|
243
|
+
raise HookError(
|
|
244
|
+
f"Python plugin {relative_path}:{callable_name} could not be loaded safely: "
|
|
245
|
+
f"{str(exc).removeprefix('Python plugin ')}"
|
|
246
|
+
) from None
|
|
247
|
+
# ``callback_path`` is canonical after containment checks and is equal
|
|
248
|
+
# to the lexical cache key for a safe repository file.
|
|
249
|
+
try:
|
|
250
|
+
source_text = source.decode(_SOURCE_ENCODING)
|
|
251
|
+
code = compile(source_text, str(callback_path), "exec")
|
|
252
|
+
except UnicodeDecodeError:
|
|
253
|
+
raise HookError(
|
|
254
|
+
f"Python plugin {relative_path}:{callable_name} source is not valid UTF-8"
|
|
255
|
+
) from None
|
|
256
|
+
except (SyntaxError, ValueError, TypeError):
|
|
257
|
+
raise HookError(
|
|
258
|
+
f"Python plugin {relative_path}:{callable_name} could not be compiled"
|
|
259
|
+
) from None
|
|
260
|
+
|
|
261
|
+
with self._lock:
|
|
262
|
+
self._module_number += 1
|
|
263
|
+
module_number = self._module_number
|
|
264
|
+
digest = hashlib.sha256(f"{root}\0{callback_path}".encode()).hexdigest()[:16]
|
|
265
|
+
module_name = f"ai_push_hooks_plugin_{digest}_{module_number}"
|
|
266
|
+
module = ModuleType(module_name)
|
|
267
|
+
module.__file__ = str(callback_path)
|
|
268
|
+
module.__package__ = ""
|
|
269
|
+
try:
|
|
270
|
+
exec(code, module.__dict__) # noqa: S102
|
|
271
|
+
except KeyboardInterrupt:
|
|
272
|
+
raise
|
|
273
|
+
except SystemExit:
|
|
274
|
+
raise HookError(
|
|
275
|
+
f"Python plugin {relative_path}:{callable_name} exited during import"
|
|
276
|
+
) from None
|
|
277
|
+
except ModuleNotFoundError as exc:
|
|
278
|
+
if exc.name:
|
|
279
|
+
detail = f"is missing dependency {exc.name!r}"
|
|
280
|
+
else:
|
|
281
|
+
detail = "could not import a dependency"
|
|
282
|
+
raise HookError(
|
|
283
|
+
f"Python plugin {relative_path}:{callable_name} {detail}"
|
|
284
|
+
) from None
|
|
285
|
+
except ImportError:
|
|
286
|
+
raise HookError(
|
|
287
|
+
f"Python plugin {relative_path}:{callable_name} could not import a dependency"
|
|
288
|
+
) from None
|
|
289
|
+
except Exception: # noqa: BLE001
|
|
290
|
+
raise HookError(
|
|
291
|
+
f"Python plugin {relative_path}:{callable_name} failed during import"
|
|
292
|
+
) from None
|
|
293
|
+
return module
|
|
294
|
+
|
|
179
295
|
def load(self, repo_root: pathlib.Path, reference: str) -> Callable[..., Any]:
|
|
180
296
|
"""Return the named top-level callable from a safe source snapshot.
|
|
181
297
|
|
|
182
|
-
|
|
183
|
-
execution. Thus concurrent collectors cannot execute the
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
remain
|
|
298
|
+
A per-source lock includes descriptor open, source read, compilation,
|
|
299
|
+
and module execution. Thus concurrent collectors cannot execute the
|
|
300
|
+
same source twice, while different sources can import concurrently.
|
|
301
|
+
The cache key includes both canonical repository root and canonical
|
|
302
|
+
file path, so identical relative names in two repositories remain
|
|
303
|
+
independent.
|
|
187
304
|
"""
|
|
188
305
|
|
|
189
306
|
relative_path, callable_name = _reference_parts(reference)
|
|
190
307
|
root = _canonical_root(repo_root)
|
|
308
|
+
lexical_callback_path = root.joinpath(*relative_path.split("/"))
|
|
309
|
+
key = (root, lexical_callback_path)
|
|
191
310
|
with self._lock:
|
|
192
|
-
lexical_callback_path = root.joinpath(*relative_path.split("/"))
|
|
193
|
-
key = (root, lexical_callback_path)
|
|
194
311
|
module = self._cache.get(key)
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
digest = hashlib.sha256(
|
|
220
|
-
f"{root}\0{callback_path}".encode()
|
|
221
|
-
).hexdigest()[:16]
|
|
222
|
-
module_name = f"ai_push_hooks_plugin_{digest}_{self._module_number}"
|
|
223
|
-
module = ModuleType(module_name)
|
|
224
|
-
module.__file__ = str(callback_path)
|
|
225
|
-
module.__package__ = ""
|
|
226
|
-
try:
|
|
227
|
-
exec(code, module.__dict__) # noqa: S102
|
|
228
|
-
except KeyboardInterrupt:
|
|
229
|
-
raise
|
|
230
|
-
except SystemExit:
|
|
231
|
-
raise HookError(
|
|
232
|
-
f"Python plugin {relative_path}:{callable_name} exited during import"
|
|
233
|
-
) from None
|
|
234
|
-
except ModuleNotFoundError as exc:
|
|
235
|
-
if exc.name:
|
|
236
|
-
detail = f"is missing dependency {exc.name!r}"
|
|
237
|
-
else:
|
|
238
|
-
detail = "could not import a dependency"
|
|
239
|
-
raise HookError(
|
|
240
|
-
f"Python plugin {relative_path}:{callable_name} {detail}"
|
|
241
|
-
) from None
|
|
242
|
-
except ImportError:
|
|
243
|
-
raise HookError(
|
|
244
|
-
f"Python plugin {relative_path}:{callable_name} could not import a dependency"
|
|
245
|
-
) from None
|
|
246
|
-
except Exception: # noqa: BLE001
|
|
247
|
-
raise HookError(
|
|
248
|
-
f"Python plugin {relative_path}:{callable_name} failed during import"
|
|
249
|
-
) from None
|
|
250
|
-
self._cache[key] = module
|
|
251
|
-
|
|
252
|
-
try:
|
|
253
|
-
callback = getattr(module, callable_name)
|
|
254
|
-
except AttributeError:
|
|
255
|
-
raise _failure(
|
|
256
|
-
"callback", relative_path, callable_name, "was not defined"
|
|
257
|
-
) from None
|
|
258
|
-
if not callable(callback):
|
|
259
|
-
raise _failure(
|
|
260
|
-
"callback", relative_path, callable_name, "is not callable"
|
|
261
|
-
) from None
|
|
262
|
-
if inspect.iscoroutinefunction(callback):
|
|
263
|
-
raise _failure(
|
|
264
|
-
"callback", relative_path, callable_name, "must be synchronous"
|
|
265
|
-
) from None
|
|
266
|
-
return callback
|
|
312
|
+
if module is None:
|
|
313
|
+
with self._source_lock(key):
|
|
314
|
+
with self._lock:
|
|
315
|
+
module = self._cache.get(key)
|
|
316
|
+
if module is None:
|
|
317
|
+
module = self._load_module(root, relative_path, callable_name)
|
|
318
|
+
with self._lock:
|
|
319
|
+
self._cache[key] = module
|
|
320
|
+
|
|
321
|
+
try:
|
|
322
|
+
callback = getattr(module, callable_name)
|
|
323
|
+
except AttributeError:
|
|
324
|
+
raise _failure(
|
|
325
|
+
"callback", relative_path, callable_name, "was not defined"
|
|
326
|
+
) from None
|
|
327
|
+
if not callable(callback):
|
|
328
|
+
raise _failure(
|
|
329
|
+
"callback", relative_path, callable_name, "is not callable"
|
|
330
|
+
) from None
|
|
331
|
+
if inspect.iscoroutinefunction(callback):
|
|
332
|
+
raise _failure(
|
|
333
|
+
"callback", relative_path, callable_name, "must be synchronous"
|
|
334
|
+
) from None
|
|
335
|
+
return callback
|
|
267
336
|
|
|
268
337
|
def invoke(
|
|
269
338
|
self,
|
|
@@ -297,13 +366,17 @@ class PluginLoader:
|
|
|
297
366
|
except SystemExit:
|
|
298
367
|
raise _failure(stage, relative_path, callable_name, "exited") from None
|
|
299
368
|
except Exception: # noqa: BLE001
|
|
300
|
-
raise _failure(
|
|
369
|
+
raise _failure(
|
|
370
|
+
stage, relative_path, callable_name, "raised an exception"
|
|
371
|
+
) from None
|
|
301
372
|
|
|
302
373
|
if inspect.isawaitable(value):
|
|
303
374
|
close = getattr(value, "close", None)
|
|
304
375
|
if callable(close):
|
|
305
376
|
close()
|
|
306
|
-
raise _failure(
|
|
377
|
+
raise _failure(
|
|
378
|
+
stage, relative_path, callable_name, "must return synchronously"
|
|
379
|
+
)
|
|
307
380
|
return value
|
|
308
381
|
|
|
309
382
|
# Explicit aliases make the intended internal seam easy to integrate while
|
|
@@ -320,7 +393,9 @@ def _ordered_inputs(
|
|
|
320
393
|
extra = [reference for reference in input_paths if reference not in declared]
|
|
321
394
|
if extra:
|
|
322
395
|
raise HookError(f"Undeclared Python plugin input: {extra[0]}")
|
|
323
|
-
missing = [
|
|
396
|
+
missing = [
|
|
397
|
+
reference for reference in step.inputs if reference not in input_paths
|
|
398
|
+
]
|
|
324
399
|
if missing:
|
|
325
400
|
raise HookError(f"Missing resolved Python plugin input: {missing[0]}")
|
|
326
401
|
return {reference: input_paths[reference] for reference in step.inputs}
|
|
@@ -358,8 +433,8 @@ def build_plugin_context(
|
|
|
358
433
|
module_id=state.module.id,
|
|
359
434
|
step_id=step.id,
|
|
360
435
|
inputs=_ordered_inputs(step, input_paths),
|
|
361
|
-
options=
|
|
362
|
-
prior_module_metadata=
|
|
436
|
+
options=step.options,
|
|
437
|
+
prior_module_metadata=state.metadata,
|
|
363
438
|
push=push,
|
|
364
439
|
logger=runtime.logger,
|
|
365
440
|
)
|
|
@@ -379,7 +454,9 @@ class PluginDispatcher:
|
|
|
379
454
|
input_paths: Mapping[str, pathlib.Path] | Sequence[pathlib.Path],
|
|
380
455
|
) -> Any:
|
|
381
456
|
if not step.python:
|
|
382
|
-
raise HookError(
|
|
457
|
+
raise HookError(
|
|
458
|
+
f"Python {step.type} step `{step.id}` has no callback reference"
|
|
459
|
+
)
|
|
383
460
|
context = build_plugin_context(runtime, state, step, input_paths)
|
|
384
461
|
return self.loader.invoke(
|
|
385
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
|
|
@@ -14,6 +14,7 @@ from typing import Any, ClassVar
|
|
|
14
14
|
READ_ONLY_STEP_TYPES = frozenset({"collect", "ask"})
|
|
15
15
|
PROMPTABLE_STEP_TYPES = frozenset({"ask", "apply"})
|
|
16
16
|
SUPPORTED_STEP_TYPES = frozenset({"collect", "ask", "apply", "exec", "assert"})
|
|
17
|
+
DEFAULT_STEP_COMMAND_TIMEOUT_SECONDS = 60
|
|
17
18
|
FEATURE_BRANCH_PREFIXES = ("feat/", "feature/")
|
|
18
19
|
ZERO_OID_LENGTHS = frozenset({40, 64})
|
|
19
20
|
|
|
@@ -80,7 +81,7 @@ class GeneralConfig:
|
|
|
80
81
|
@dataclass(frozen=True)
|
|
81
82
|
class LlmConfig:
|
|
82
83
|
runner: str = "opencode"
|
|
83
|
-
model: str = "openai/gpt-5.6-
|
|
84
|
+
model: str = "openai/gpt-5.6-luna"
|
|
84
85
|
variant: str = ""
|
|
85
86
|
timeout_seconds: int = 800
|
|
86
87
|
max_parallel: int = 2
|
|
@@ -177,10 +178,7 @@ class CollectorResult:
|
|
|
177
178
|
|
|
178
179
|
@dataclass
|
|
179
180
|
class StepResult:
|
|
180
|
-
status: str = "completed"
|
|
181
|
-
artifacts: dict[str, pathlib.Path] = field(default_factory=dict)
|
|
182
181
|
metadata: dict[str, Any] = field(default_factory=dict)
|
|
183
|
-
message: str = ""
|
|
184
182
|
|
|
185
183
|
|
|
186
184
|
@dataclass
|
|
@@ -327,7 +325,9 @@ class HookLogger:
|
|
|
327
325
|
call_number = cls._safe_text(fields.get("call_number", ""))
|
|
328
326
|
stage = cls._stage_for_console(fields.get("stage_name", ""), colors_enabled)
|
|
329
327
|
purpose = cls._style(
|
|
330
|
-
cls._safe_text(fields.get("purpose", ""))
|
|
328
|
+
cls._safe_text(fields.get("purpose", ""))
|
|
329
|
+
.replace("\n", "\\n")
|
|
330
|
+
.replace("\t", " "),
|
|
331
331
|
"34",
|
|
332
332
|
colors_enabled,
|
|
333
333
|
)
|
|
@@ -350,12 +350,16 @@ class HookLogger:
|
|
|
350
350
|
call_number = cls._safe_text(fields.get("call_number", ""))
|
|
351
351
|
stage = cls._stage_for_console(fields.get("stage_name", ""), colors_enabled)
|
|
352
352
|
profile = cls._style(
|
|
353
|
-
cls._safe_text(fields.get("runner_profile", ""))
|
|
353
|
+
cls._safe_text(fields.get("runner_profile", ""))
|
|
354
|
+
.replace("\n", "\\n")
|
|
355
|
+
.replace("\t", " "),
|
|
354
356
|
"34",
|
|
355
357
|
colors_enabled,
|
|
356
358
|
)
|
|
357
359
|
runner_type = cls._style(
|
|
358
|
-
cls._safe_text(fields.get("runner_type", ""))
|
|
360
|
+
cls._safe_text(fields.get("runner_type", ""))
|
|
361
|
+
.replace("\n", "\\n")
|
|
362
|
+
.replace("\t", " "),
|
|
359
363
|
"34",
|
|
360
364
|
colors_enabled,
|
|
361
365
|
)
|
|
@@ -376,7 +380,9 @@ class HookLogger:
|
|
|
376
380
|
+ cls._style(")", "2", colors_enabled)
|
|
377
381
|
)
|
|
378
382
|
if "; " in safe_message:
|
|
379
|
-
body += cls._style(
|
|
383
|
+
body += cls._style(
|
|
384
|
+
"; " + safe_message.split("; ", 1)[1], "2", colors_enabled
|
|
385
|
+
)
|
|
380
386
|
return body
|
|
381
387
|
|
|
382
388
|
return safe_message
|
|
@@ -447,13 +453,16 @@ class HookLogger:
|
|
|
447
453
|
if initial_metadata is not None:
|
|
448
454
|
reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
|
|
449
455
|
if stat.S_ISLNK(initial_metadata.st_mode) or bool(
|
|
450
|
-
getattr(initial_metadata, "st_file_attributes", 0)
|
|
456
|
+
getattr(initial_metadata, "st_file_attributes", 0)
|
|
457
|
+
& reparse_flag
|
|
451
458
|
):
|
|
452
459
|
raise HookError(
|
|
453
460
|
"JSONL log target must not be a symlink or reparse point: "
|
|
454
461
|
f"{self.jsonl_path}"
|
|
455
462
|
)
|
|
456
|
-
flags =
|
|
463
|
+
flags = (
|
|
464
|
+
os.O_WRONLY | os.O_APPEND | os.O_CREAT | getattr(os, "O_CLOEXEC", 0)
|
|
465
|
+
)
|
|
457
466
|
flags |= getattr(os, "O_NOFOLLOW", 0)
|
|
458
467
|
descriptor = os.open(self.jsonl_path, flags, 0o600)
|
|
459
468
|
try:
|
|
@@ -464,12 +473,15 @@ class HookLogger:
|
|
|
464
473
|
not stat.S_ISREG(descriptor_metadata.st_mode)
|
|
465
474
|
or stat.S_ISLNK(path_metadata.st_mode)
|
|
466
475
|
or bool(
|
|
467
|
-
getattr(path_metadata, "st_file_attributes", 0)
|
|
476
|
+
getattr(path_metadata, "st_file_attributes", 0)
|
|
477
|
+
& reparse_flag
|
|
468
478
|
)
|
|
469
479
|
or (descriptor_metadata.st_dev, descriptor_metadata.st_ino)
|
|
470
480
|
!= (path_metadata.st_dev, path_metadata.st_ino)
|
|
471
481
|
):
|
|
472
|
-
raise HookError(
|
|
482
|
+
raise HookError(
|
|
483
|
+
f"JSONL log target is not a regular file: {self.jsonl_path}"
|
|
484
|
+
)
|
|
473
485
|
os.fchmod(descriptor, 0o600)
|
|
474
486
|
os.write(
|
|
475
487
|
descriptor,
|
|
@@ -521,7 +533,9 @@ class HookLogger:
|
|
|
521
533
|
"purpose": safe_purpose,
|
|
522
534
|
"model": self._safe_text(model),
|
|
523
535
|
"module": safe_stage.split(".", 1)[0],
|
|
524
|
-
"step": safe_stage.split(".", 1)[1]
|
|
536
|
+
"step": safe_stage.split(".", 1)[1]
|
|
537
|
+
if "." in safe_stage
|
|
538
|
+
else safe_stage,
|
|
525
539
|
}
|
|
526
540
|
if attempt is not None:
|
|
527
541
|
record["attempt"] = attempt
|
|
@@ -558,16 +572,20 @@ class HookLogger:
|
|
|
558
572
|
safe_stage = self._safe_text(stage_name)
|
|
559
573
|
safe_profile = self._safe_text(runner_profile)
|
|
560
574
|
safe_type = self._safe_text(runner_type)
|
|
561
|
-
safe_state =
|
|
562
|
-
|
|
563
|
-
|
|
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
|
+
)
|
|
564
584
|
safe_resume_command = (
|
|
565
585
|
self._safe_text(resume_command) if resume_command is not None else None
|
|
566
586
|
)
|
|
567
587
|
effective_resume_command = (
|
|
568
|
-
safe_resume_command
|
|
569
|
-
if safe_state == "persisted" and resumable
|
|
570
|
-
else None
|
|
588
|
+
safe_resume_command if safe_state == "persisted" and resumable else None
|
|
571
589
|
)
|
|
572
590
|
session_details: list[str] = []
|
|
573
591
|
if safe_state == "persisted":
|
|
@@ -621,15 +639,18 @@ class HookLogger:
|
|
|
621
639
|
fields["transcript"] = safe_transcript
|
|
622
640
|
if effective_resume_command is not None:
|
|
623
641
|
fields["resume_command"] = effective_resume_command
|
|
624
|
-
if
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
+
)
|
|
631
651
|
)
|
|
632
|
-
|
|
652
|
+
or resumable
|
|
653
|
+
):
|
|
633
654
|
fields["resumable"] = resumable
|
|
634
655
|
self.status("llm.complete", message, **fields)
|
|
635
656
|
|
package/vendor/README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Bundled Python Dependency
|
|
2
|
+
|
|
3
|
+
The npm package includes the unmodified pure-Python wheel for [Tomli](https://pypi.org/project/tomli/2.4.0/). Python 3.10 uses it to parse TOML; Python 3.11+ uses the standard-library `tomllib` module.
|
|
4
|
+
|
|
5
|
+
The wrapper adds the wheel to `PYTHONPATH`, so no pip install, install script, or runtime download is needed. The wheel includes Tomli's MIT license under `tomli-2.4.0.dist-info/licenses/LICENSE`.
|
|
6
|
+
|
|
7
|
+
To reproduce the bundled download from the repository root:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
python -m pip download --index-url https://pypi.org/simple --require-hashes \
|
|
11
|
+
--only-binary=:all: --no-deps --platform any --implementation py --abi none \
|
|
12
|
+
--python-version 3.10 --dest vendor --requirement vendor/requirements.txt
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
When updating, verify the pure-Python wheel's SHA-256 against PyPI, update the pin and wrapper path, and run the npm installed-hook tests on Python 3.10.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tomli==2.4.0 --hash=sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a
|
|
Binary file
|