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.
Files changed (40) hide show
  1. package/CHANGELOG.md +78 -2
  2. package/README.md +80 -993
  3. package/SECURITY.md +21 -16
  4. package/ai-push-hooks.toml +1 -1
  5. package/bin/ai-push-hooks.js +6 -6
  6. package/docs/configuration.md +263 -0
  7. package/package.json +4 -3
  8. package/pyproject.toml +12 -3
  9. package/src/ai_push_hooks/artifacts.py +19 -19
  10. package/src/ai_push_hooks/cli.py +24 -8
  11. package/src/ai_push_hooks/config.py +239 -54
  12. package/src/ai_push_hooks/engine.py +94 -43
  13. package/src/ai_push_hooks/executors/apply.py +124 -46
  14. package/src/ai_push_hooks/executors/ask.py +30 -536
  15. package/src/ai_push_hooks/executors/exec.py +73 -818
  16. package/src/ai_push_hooks/executors/runner_workflow.py +42 -20
  17. package/src/ai_push_hooks/executors/runners/claude.py +18 -6
  18. package/src/ai_push_hooks/executors/runners/codex.py +9 -3
  19. package/src/ai_push_hooks/executors/runners/command.py +25 -9
  20. package/src/ai_push_hooks/executors/runners/contracts.py +52 -25
  21. package/src/ai_push_hooks/executors/runners/opencode.py +109 -27
  22. package/src/ai_push_hooks/executors/runners/opencode_support.py +286 -0
  23. package/src/ai_push_hooks/executors/runners/process.py +97 -17
  24. package/src/ai_push_hooks/executors/runners/registry.py +31 -9
  25. package/src/ai_push_hooks/executors/step_commands.py +65 -20
  26. package/src/ai_push_hooks/git_utils.py +899 -0
  27. package/src/ai_push_hooks/hook.py +49 -13
  28. package/src/ai_push_hooks/install.py +40 -18
  29. package/src/ai_push_hooks/modules/beads.py +19 -8
  30. package/src/ai_push_hooks/modules/docs.py +146 -96
  31. package/src/ai_push_hooks/modules/pr.py +19 -8
  32. package/src/ai_push_hooks/paths.py +6 -2
  33. package/src/ai_push_hooks/plugin_loader.py +182 -105
  34. package/src/ai_push_hooks/plugins.py +3 -1
  35. package/src/ai_push_hooks/prompts_builtin.py +1 -1
  36. package/src/ai_push_hooks/types.py +48 -27
  37. package/vendor/README.md +15 -0
  38. package/vendor/requirements.txt +1 -0
  39. package/vendor/tomli-2.4.0-py3-none-any.whl +0 -0
  40. 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(f"{label} escapes its intended directory through a symlink: {raw}")
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(path: pathlib.Path, content: str, *, encoding: str = "utf-8") -> None:
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 = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
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("Python plugin path must reference an ordinary regular file")
97
- with os.fdopen(file_fd, "rb") as source:
98
- file_fd = -1
99
- return root.joinpath(*parts), source.read()
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("Python plugin path must not traverse a symlink or reparse point") from None
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("Python plugin path must reference an existing regular file") from None
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(root: pathlib.Path, relative_path: str) -> tuple[pathlib.Path, bytes]:
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("Python plugin path must not traverse a symlink or reparse point")
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("Python plugin path must reference an existing regular file") from None
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 = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
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("Python plugin path must reference an ordinary regular file")
144
- with os.fdopen(descriptor, "rb") as source:
145
- descriptor = -1
146
- return callback_path, source.read()
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(stage: str, relative_path: str, callable_name: str, detail: str) -> HookError:
166
- return HookError(
167
- f"Python {stage} plugin {relative_path}:{callable_name} {detail}"
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
- The lock includes descriptor open, source read, compilation, and module
183
- execution. Thus concurrent collectors cannot execute the same source
184
- twice. The cache key includes both canonical repository root and
185
- canonical file path, so identical relative names in two repositories
186
- remain independent.
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
- if module is None:
196
- try:
197
- callback_path, source = _open_source(root, relative_path)
198
- except HookError as exc:
199
- raise HookError(
200
- f"Python plugin {relative_path}:{callable_name} could not be loaded safely: "
201
- f"{str(exc).removeprefix('Python plugin ')}"
202
- ) from None
203
- # ``callback_path`` is canonical after containment checks and
204
- # is equal to the lexical path for a safe repository file.
205
- key = (root, callback_path)
206
- try:
207
- source_text = source.decode(_SOURCE_ENCODING)
208
- code = compile(source_text, str(callback_path), "exec")
209
- except UnicodeDecodeError:
210
- raise HookError(
211
- f"Python plugin {relative_path}:{callable_name} source is not valid UTF-8"
212
- ) from None
213
- except (SyntaxError, ValueError, TypeError):
214
- raise HookError(
215
- f"Python plugin {relative_path}:{callable_name} could not be compiled"
216
- ) from None
217
-
218
- self._module_number += 1
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(stage, relative_path, callable_name, "raised an exception") from None
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(stage, relative_path, callable_name, "must return synchronously")
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 = [reference for reference in step.inputs if reference not in input_paths]
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=copy.deepcopy(step.options),
362
- prior_module_metadata=copy.deepcopy(state.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(f"Python {step.type} step `{step.id}` has no callback reference")
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__(self, "prior_module_metadata", _freeze(self.prior_module_metadata))
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-terra"
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-terra"
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", "")).replace("\n", "\\n").replace("\t", " "),
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", "")).replace("\n", "\\n").replace("\t", " "),
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", "")).replace("\n", "\\n").replace("\t", " "),
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("; " + safe_message.split("; ", 1)[1], "2", colors_enabled)
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) & reparse_flag
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 = os.O_WRONLY | os.O_APPEND | os.O_CREAT | getattr(os, "O_CLOEXEC", 0)
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) & reparse_flag
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(f"JSONL log target is not a regular file: {self.jsonl_path}")
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] if "." in safe_stage else safe_stage,
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 = self._safe_text(session_state) if session_state is not None else None
562
- safe_session_id = self._safe_text(session_id) if session_id is not None else None
563
- safe_transcript = self._safe_text(transcript) if transcript is not None else None
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 any(
625
- value is not None
626
- for value in (
627
- safe_session_id,
628
- safe_state,
629
- safe_transcript,
630
- effective_resume_command,
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
- ) or resumable:
652
+ or resumable
653
+ ):
633
654
  fields["resumable"] = resumable
634
655
  self.status("llm.complete", message, **fields)
635
656
 
@@ -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