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.
Files changed (35) hide show
  1. package/CHANGELOG.md +49 -1
  2. package/README.md +91 -80
  3. package/ai-push-hooks.toml +1 -1
  4. package/docs/configuration.md +263 -0
  5. package/package.json +3 -3
  6. package/pyproject.toml +12 -3
  7. package/src/ai_push_hooks/artifacts.py +19 -6
  8. package/src/ai_push_hooks/cli.py +24 -8
  9. package/src/ai_push_hooks/config.py +220 -49
  10. package/src/ai_push_hooks/engine.py +93 -43
  11. package/src/ai_push_hooks/executors/apply.py +121 -43
  12. package/src/ai_push_hooks/executors/ask.py +30 -13
  13. package/src/ai_push_hooks/executors/exec.py +61 -22
  14. package/src/ai_push_hooks/executors/runner_workflow.py +38 -16
  15. package/src/ai_push_hooks/executors/runners/claude.py +18 -6
  16. package/src/ai_push_hooks/executors/runners/codex.py +9 -3
  17. package/src/ai_push_hooks/executors/runners/command.py +25 -9
  18. package/src/ai_push_hooks/executors/runners/contracts.py +52 -25
  19. package/src/ai_push_hooks/executors/runners/opencode.py +74 -21
  20. package/src/ai_push_hooks/executors/runners/opencode_support.py +15 -5
  21. package/src/ai_push_hooks/executors/runners/process.py +26 -7
  22. package/src/ai_push_hooks/executors/runners/registry.py +14 -5
  23. package/src/ai_push_hooks/executors/step_commands.py +58 -18
  24. package/src/ai_push_hooks/git_utils.py +92 -27
  25. package/src/ai_push_hooks/hook.py +48 -12
  26. package/src/ai_push_hooks/install.py +40 -18
  27. package/src/ai_push_hooks/modules/beads.py +18 -7
  28. package/src/ai_push_hooks/modules/docs.py +17 -7
  29. package/src/ai_push_hooks/modules/pr.py +18 -7
  30. package/src/ai_push_hooks/paths.py +6 -2
  31. package/src/ai_push_hooks/plugin_loader.py +79 -26
  32. package/src/ai_push_hooks/plugins.py +3 -1
  33. package/src/ai_push_hooks/prompts_builtin.py +1 -1
  34. package/src/ai_push_hooks/types.py +47 -27
  35. 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(context: RuntimeContext, state: Any) -> CollectorResult:
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(branch_name):
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("branch_diff_text", context.cache.get("diff_text", ""))
37
- commits = collect_commit_messages_for_ranges(context.repo_root, ranges) if ranges else []
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) + ("\n" if changed_files else ""),
58
- "push.diff": diff_text + ("\n" if diff_text and not diff_text.endswith("\n") else ""),
59
- "commits.txt": "\n".join(commit_lines).strip() + ("\n" if commit_lines else ""),
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(repo_root: pathlib.Path, doc_files: list[pathlib.Path], queries: list[str]) -> str:
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 = collect_commit_messages_for_ranges(context.repo_root, ranges) if ranges else []
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) + ("\n" if changed_files else ""),
312
- "push.diff": diff_text + ("\n" if diff_text and not diff_text.endswith("\n") else ""),
313
- "docs-inventory.txt": "\n".join(path.relative_to(context.repo_root).as_posix() for path in doc_files)
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 + ("\n" if recent_commits and not recent_commits.endswith("\n") else ""),
317
- "commits.txt": "\n".join(commit_lines).strip() + ("\n" if commit_lines else ""),
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={"pr-context.txt": f"branch={branch_name}\nflag_env={flag_env}\n"},
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={"pr-context.txt": f"branch={branch_name}\nexisting_pr_url={existing_pr_url}\n"},
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("branch_diff_text", context.cache.get("diff_text", ""))
95
- commits = collect_commit_messages_for_ranges(context.repo_root, ranges) if ranges else []
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) + ("\n" if changed_files else ""),
117
- "push.diff": diff_text + ("\n" if diff_text and not diff_text.endswith("\n") else ""),
118
- "commits.txt": "\n".join(commit_lines).strip() + ("\n" if commit_lines else ""),
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(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))
@@ -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 = 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
+ )
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("Python plugin path must reference an ordinary regular file")
96
- with os.fdopen(file_fd, "rb") as source:
97
- file_fd = -1
98
- 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)
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("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
104
138
  if exc.errno == errno.ENOENT:
105
- 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
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(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]:
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("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
+ )
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("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
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 = 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
+ )
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("Python plugin path must reference an ordinary regular file")
143
- with os.fdopen(descriptor, "rb") as source:
144
- descriptor = -1
145
- 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)
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(stage: str, relative_path: str, callable_name: str, detail: str) -> HookError:
165
- return HookError(
166
- f"Python {stage} plugin {relative_path}:{callable_name} {detail}"
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(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
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(stage, relative_path, callable_name, "must return synchronously")
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 = [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
+ ]
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(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
+ )
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__(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
@@ -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-terra"
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", "")).replace("\n", "\\n").replace("\t", " "),
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", "")).replace("\n", "\\n").replace("\t", " "),
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", "")).replace("\n", "\\n").replace("\t", " "),
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("; " + safe_message.split("; ", 1)[1], "2", colors_enabled)
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) & reparse_flag
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 = 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
+ )
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) & reparse_flag
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(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
+ )
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] if "." in safe_stage else safe_stage,
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 = self._safe_text(session_state) if session_state is not None else None
563
- safe_session_id = self._safe_text(session_id) if session_id is not None else None
564
- 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
+ )
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 any(
626
- value is not None
627
- for value in (
628
- safe_session_id,
629
- safe_state,
630
- safe_transcript,
631
- 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
+ )
632
651
  )
633
- ) or resumable:
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 "$@"