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
@@ -1,16 +1,18 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import pathlib
4
+ from collections.abc import Callable
4
5
  from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait
5
- from typing import Any, Callable
6
+ from typing import Any
6
7
 
7
8
  from .artifacts import ArtifactStore
8
9
  from .config import resolve_prompt_text
9
10
  from .executors.apply import run_apply_step
10
- from .executors.assertions import ASSERTION_HANDLERS
11
- from .executors.exec import EXEC_HANDLERS, env_bool
12
11
  from .executors.ask import run_ask_step
12
+ from .executors.assertions import ASSERTION_HANDLERS
13
+ from .executors.exec import EXEC_HANDLERS
13
14
  from .executors.step_commands import execute_step_command
15
+ from .git_utils import env_bool
14
16
  from .modules import COLLECTORS
15
17
  from .plugin_loader import PluginDispatcher
16
18
  from .plugins import (
@@ -29,8 +31,12 @@ from .types import (
29
31
  )
30
32
 
31
33
  CollectorHandler = Callable[[RuntimeContext, ModuleRuntimeState], CollectorResult]
32
- ExecHandler = Callable[[RuntimeContext, ModuleRuntimeState, StepConfig, list[pathlib.Path]], dict[str, Any]]
33
- AssertionHandler = Callable[[RuntimeContext, StepConfig, list[pathlib.Path]], dict[str, Any]]
34
+ ExecHandler = Callable[
35
+ [RuntimeContext, ModuleRuntimeState, StepConfig, list[pathlib.Path]], dict[str, Any]
36
+ ]
37
+ AssertionHandler = Callable[
38
+ [RuntimeContext, StepConfig, list[pathlib.Path]], dict[str, Any]
39
+ ]
34
40
 
35
41
 
36
42
  class WorkflowEngine:
@@ -41,8 +47,20 @@ class WorkflowEngine:
41
47
  collectors: dict[str, CollectorHandler] | None = None,
42
48
  exec_handlers: dict[str, ExecHandler] | None = None,
43
49
  assertion_handlers: dict[str, AssertionHandler] | None = None,
44
- ask_executor: Callable[[RuntimeContext, StepConfig, str, list[pathlib.Path], str], Any] = run_ask_step,
45
- apply_executor: Callable[[RuntimeContext, ModuleRuntimeState, StepConfig, str, list[pathlib.Path], str], dict[str, object]] = run_apply_step,
50
+ ask_executor: Callable[
51
+ [RuntimeContext, StepConfig, str, list[pathlib.Path], str], Any
52
+ ] = run_ask_step,
53
+ apply_executor: Callable[
54
+ [
55
+ RuntimeContext,
56
+ ModuleRuntimeState,
57
+ StepConfig,
58
+ str,
59
+ list[pathlib.Path],
60
+ str,
61
+ ],
62
+ dict[str, object],
63
+ ] = run_apply_step,
46
64
  ) -> None:
47
65
  self.context = context
48
66
  self.artifacts = artifacts
@@ -67,7 +85,9 @@ class WorkflowEngine:
67
85
  statuses: dict[str, str] = {state.module.id: "pending" for state in states}
68
86
  futures: dict[Future[StepResult], tuple[ModuleRuntimeState, StepConfig]] = {}
69
87
 
70
- with ThreadPoolExecutor(max_workers=max(1, self.context.config.llm.max_parallel)) as pool:
88
+ with ThreadPoolExecutor(
89
+ max_workers=max(1, self.context.config.llm.max_parallel)
90
+ ) as pool:
71
91
  while True:
72
92
  for state in states:
73
93
  if state.status in {"completed", "failed"}:
@@ -82,9 +102,14 @@ class WorkflowEngine:
82
102
  continue
83
103
  if futures and not step.is_read_only:
84
104
  continue
85
- if any(not running_step.is_read_only for _future, (_state, running_step) in futures.items()):
105
+ if any(
106
+ not running_step.is_read_only
107
+ for _future, (_state, running_step) in futures.items()
108
+ ):
86
109
  continue
87
- if step.is_read_only and len(futures) >= max(1, self.context.config.llm.max_parallel):
110
+ if step.is_read_only and len(futures) >= max(
111
+ 1, self.context.config.llm.max_parallel
112
+ ):
88
113
  continue
89
114
  future = pool.submit(self._execute_step, state, step)
90
115
  futures[future] = (state, step)
@@ -96,8 +121,15 @@ class WorkflowEngine:
96
121
  if not futures:
97
122
  if all(state.status == "completed" for state in states):
98
123
  break
99
- pending = [state.module.id for state in states if state.status not in {"completed", "failed"}]
100
- raise HookError("Scheduler deadlock while running modules: " + ", ".join(pending))
124
+ pending = [
125
+ state.module.id
126
+ for state in states
127
+ if state.status not in {"completed", "failed"}
128
+ ]
129
+ raise HookError(
130
+ "Scheduler deadlock while running modules: "
131
+ + ", ".join(pending)
132
+ )
101
133
 
102
134
  done, _ = wait(set(futures), return_when=FIRST_COMPLETED)
103
135
  for future in done:
@@ -105,7 +137,7 @@ class WorkflowEngine:
105
137
  state.active_step_id = None
106
138
  try:
107
139
  result = future.result()
108
- except Exception as exc: # noqa: BLE001
140
+ except Exception as exc:
109
141
  state.status = "failed"
110
142
  state.error = str(exc)
111
143
  raise
@@ -126,8 +158,10 @@ class WorkflowEngine:
126
158
  def _execute_step(self, state: ModuleRuntimeState, step: StepConfig) -> StepResult:
127
159
  if step.when_env and env_bool(step.when_env) is not True:
128
160
  payload = {"skipped": True, "reason": f"{step.when_env} not enabled"}
129
- path = self.artifacts.write_json(state, state.step_index, step.id, "result.json", payload)
130
- return StepResult(status="skipped", artifacts={"result.json": path}, metadata={})
161
+ self.artifacts.write_json(
162
+ state, state.step_index, step.id, "result.json", payload
163
+ )
164
+ return StepResult()
131
165
 
132
166
  if step.type == "collect":
133
167
  if step.python:
@@ -138,24 +172,36 @@ class WorkflowEngine:
138
172
  return self._persist_plugin_collect(state, step, result)
139
173
  return self._run_collect(state, step)
140
174
 
141
- input_paths = [self.artifacts.resolve_input(state, reference) for reference in step.inputs]
175
+ input_paths = [
176
+ self.artifacts.resolve_input(state, reference) for reference in step.inputs
177
+ ]
142
178
  stage_name = f"{state.module.id}.{step.id}"
143
179
 
144
180
  if step.type == "ask":
145
181
  prompt = resolve_prompt_text(self.context.repo_root, step)
146
- payload = self.ask_executor(self.context, step, prompt, input_paths, stage_name)
182
+ payload = self.ask_executor(
183
+ self.context, step, prompt, input_paths, stage_name
184
+ )
147
185
  artifact_name = step.output or "result.json"
148
186
  if isinstance(payload, (dict, list)) or artifact_name.endswith(".json"):
149
- path = self.artifacts.write_json(state, state.step_index, step.id, artifact_name, payload)
187
+ self.artifacts.write_json(
188
+ state, state.step_index, step.id, artifact_name, payload
189
+ )
150
190
  else:
151
- path = self.artifacts.write_text(state, state.step_index, step.id, artifact_name, str(payload))
152
- return StepResult(artifacts={artifact_name: path})
191
+ self.artifacts.write_text(
192
+ state, state.step_index, step.id, artifact_name, str(payload)
193
+ )
194
+ return StepResult()
153
195
 
154
196
  if step.type == "apply":
155
197
  prompt = resolve_prompt_text(self.context.repo_root, step)
156
- payload = self.apply_executor(self.context, state, step, prompt, input_paths, stage_name)
157
- path = self.artifacts.write_json(state, state.step_index, step.id, "result.json", payload)
158
- return StepResult(artifacts={"result.json": path})
198
+ payload = self.apply_executor(
199
+ self.context, state, step, prompt, input_paths, stage_name
200
+ )
201
+ self.artifacts.write_json(
202
+ state, state.step_index, step.id, "result.json", payload
203
+ )
204
+ return StepResult()
159
205
 
160
206
  if step.type == "exec":
161
207
  if step.python:
@@ -163,23 +209,25 @@ class WorkflowEngine:
163
209
  payload = validate_exec_result(
164
210
  self._dispatch_plugin(state, step, plugin_inputs)
165
211
  )
166
- path = self._persist_plugin_result(state, step, payload)
167
- return StepResult(artifacts={"result.json": path})
212
+ self._persist_plugin_result(state, step, payload)
213
+ return StepResult()
168
214
  if step.command:
169
- persisted = execute_step_command(
215
+ execute_step_command(
170
216
  self.context,
171
217
  state,
172
218
  step,
173
219
  dict(zip(step.inputs, input_paths)),
174
220
  artifacts=self.artifacts,
175
221
  )
176
- return StepResult(artifacts=dict(persisted.artifacts))
222
+ return StepResult()
177
223
  handler = self.exec_handlers.get(step.executor or "")
178
224
  if handler is None:
179
225
  raise HookError(f"Unknown exec handler: {step.executor}")
180
226
  payload = handler(self.context, state, step, input_paths)
181
- path = self.artifacts.write_json(state, state.step_index, step.id, "result.json", payload)
182
- return StepResult(artifacts={"result.json": path})
227
+ self.artifacts.write_json(
228
+ state, state.step_index, step.id, "result.json", payload
229
+ )
230
+ return StepResult()
183
231
 
184
232
  if step.type == "assert":
185
233
  if step.python:
@@ -187,27 +235,29 @@ class WorkflowEngine:
187
235
  payload = validate_assert_result(
188
236
  self._dispatch_plugin(state, step, plugin_inputs)
189
237
  )
190
- path = self._persist_plugin_result(state, step, payload)
238
+ self._persist_plugin_result(state, step, payload)
191
239
  if not payload["ok"]:
192
240
  raise HookError(payload.get("message", "assertion failed"))
193
- return StepResult(artifacts={"result.json": path})
241
+ return StepResult()
194
242
  if step.command:
195
- persisted = execute_step_command(
243
+ execute_step_command(
196
244
  self.context,
197
245
  state,
198
246
  step,
199
247
  dict(zip(step.inputs, input_paths)),
200
248
  artifacts=self.artifacts,
201
249
  )
202
- return StepResult(artifacts=dict(persisted.artifacts))
250
+ return StepResult()
203
251
  handler = self.assertion_handlers.get(step.assertion or "")
204
252
  if handler is None:
205
253
  raise HookError(f"Unknown assertion handler: {step.assertion}")
206
254
  payload = handler(self.context, step, input_paths)
207
- path = self.artifacts.write_json(state, state.step_index, step.id, "result.json", payload)
255
+ self.artifacts.write_json(
256
+ state, state.step_index, step.id, "result.json", payload
257
+ )
208
258
  if not bool(payload.get("ok", False)):
209
259
  raise HookError(str(payload.get("message", "assertion failed")))
210
- return StepResult(artifacts={"result.json": path})
260
+ return StepResult()
211
261
 
212
262
  raise HookError(f"Unsupported step type: {step.type}")
213
263
 
@@ -237,16 +287,15 @@ class WorkflowEngine:
237
287
  ) -> StepResult:
238
288
  # Serialize and enforce both limits before the first write/register.
239
289
  serialized = self.artifacts.serialize_plugin_artifacts(result.artifacts)
240
- artifacts: dict[str, pathlib.Path] = {}
241
290
  for artifact_name, content in serialized.items():
242
- artifacts[artifact_name] = self.artifacts.write_bytes(
291
+ self.artifacts.write_bytes(
243
292
  state, state.step_index, step.id, artifact_name, content
244
293
  )
245
294
  metadata = dict(result.metadata)
246
295
  if result.skip_module:
247
296
  metadata["skip_module"] = True
248
297
  metadata["skip_reason"] = result.skip_reason
249
- return StepResult(artifacts=artifacts, metadata=metadata)
298
+ return StepResult(metadata=metadata)
250
299
 
251
300
  def _persist_plugin_result(
252
301
  self, state: ModuleRuntimeState, step: StepConfig, payload: dict[str, Any]
@@ -264,15 +313,17 @@ class WorkflowEngine:
264
313
  if handler is None:
265
314
  raise HookError(f"Unknown collector: {step.collector}")
266
315
  result = handler(self.context, state)
267
- artifacts: dict[str, pathlib.Path] = {}
268
316
  for artifact_name, payload in result.artifacts.items():
269
317
  if isinstance(payload, (dict, list)) or artifact_name.endswith(".json"):
270
- path = self.artifacts.write_json(state, state.step_index, step.id, artifact_name, payload)
318
+ self.artifacts.write_json(
319
+ state, state.step_index, step.id, artifact_name, payload
320
+ )
271
321
  else:
272
- path = self.artifacts.write_text(state, state.step_index, step.id, artifact_name, str(payload))
273
- artifacts[artifact_name] = path
322
+ self.artifacts.write_text(
323
+ state, state.step_index, step.id, artifact_name, str(payload)
324
+ )
274
325
  metadata = dict(result.metadata)
275
326
  if result.skip_module:
276
327
  metadata["skip_module"] = True
277
328
  metadata["skip_reason"] = result.skip_reason
278
- return StepResult(artifacts=artifacts, metadata=metadata)
329
+ return StepResult(metadata=metadata)
@@ -21,15 +21,15 @@ from ..paths import (
21
21
  sanitize_file_mode,
22
22
  )
23
23
  from ..types import HookError, ModuleRuntimeState, RuntimeContext, StepConfig
24
- from .exec import (
24
+ from ..git_utils import (
25
25
  list_repo_changes,
26
26
  path_matches,
27
27
  resolve_git_common_dir,
28
28
  resolve_git_dir,
29
29
  run_command,
30
30
  )
31
- from .ask import validate_opencode_attachments
32
31
  from .runner_workflow import run_runner_once
32
+ from .runners.opencode_support import validate_hook_owned_artifacts
33
33
 
34
34
  METADATA_MAX_FILES = 20_000
35
35
  METADATA_MAX_BYTES = 64 * 1024 * 1024
@@ -68,8 +68,11 @@ class ApplyOperation:
68
68
 
69
69
  def _is_protected_path(path: str) -> bool:
70
70
  parts = pathlib.PurePosixPath(path).parts
71
- return any(normalized_component(part) == PROTECTED_GIT_COMPONENT for part in parts) or (
72
- bool(parts) and normalized_component(parts[-1]) == PROTECTED_INSTRUCTION_FILENAME
71
+ return any(
72
+ normalized_component(part) == PROTECTED_GIT_COMPONENT for part in parts
73
+ ) or (
74
+ bool(parts)
75
+ and normalized_component(parts[-1]) == PROTECTED_INSTRUCTION_FILENAME
73
76
  )
74
77
 
75
78
 
@@ -102,7 +105,9 @@ def _hash_file(path: pathlib.Path, max_bytes: int | None = None) -> str:
102
105
  break
103
106
  total_bytes += len(chunk)
104
107
  if max_bytes is not None and total_bytes > max_bytes:
105
- raise HookError(f"File grew beyond bounded read budget while reading: {path}")
108
+ raise HookError(
109
+ f"File grew beyond bounded read budget while reading: {path}"
110
+ )
106
111
  digest.update(chunk)
107
112
  return digest.hexdigest()
108
113
 
@@ -116,11 +121,15 @@ def _read_regular_file(
116
121
  raise HookError(f"File exceeds bounded read budget before reading: {path}")
117
122
  content = handle.read() if max_bytes is None else handle.read(max_bytes + 1)
118
123
  if max_bytes is not None and len(content) > max_bytes:
119
- raise HookError(f"File grew beyond bounded read budget while reading: {path}")
124
+ raise HookError(
125
+ f"File grew beyond bounded read budget while reading: {path}"
126
+ )
120
127
  return content, metadata.st_mode
121
128
 
122
129
 
123
- def _snapshot_destination(repo_root: pathlib.Path, relative_path: str) -> DestinationState:
130
+ def _snapshot_destination(
131
+ repo_root: pathlib.Path, relative_path: str
132
+ ) -> DestinationState:
124
133
  destination = _repo_path_from_git(repo_root, relative_path)
125
134
  if path_has_symlink(repo_root, destination):
126
135
  return DestinationState("symlink")
@@ -143,15 +152,23 @@ def _repo_path_from_git(repo_root: pathlib.Path, path: str) -> pathlib.Path:
143
152
  return repo_root.joinpath(*pure_path.parts)
144
153
 
145
154
 
146
- def _snapshot_repo_files(repo_root: pathlib.Path, paths: set[str]) -> dict[str, FileSnapshot]:
155
+ def _snapshot_repo_files(
156
+ repo_root: pathlib.Path, paths: set[str]
157
+ ) -> dict[str, FileSnapshot]:
147
158
  if len(paths) > STAGING_MAX_FILES:
148
- raise HookError("Git-visible checkout changes exceed the bounded safety snapshot budget")
159
+ raise HookError(
160
+ "Git-visible checkout changes exceed the bounded safety snapshot budget"
161
+ )
149
162
  snapshot: dict[str, FileSnapshot] = {}
150
163
  total_bytes = 0
151
164
  for path in paths:
152
165
  full_path = _repo_path_from_git(repo_root, path)
153
166
  if path_has_symlink(repo_root, full_path):
154
- mode = full_path.lstat().st_mode if full_path.exists() or full_path.is_symlink() else None
167
+ mode = (
168
+ full_path.lstat().st_mode
169
+ if full_path.exists() or full_path.is_symlink()
170
+ else None
171
+ )
155
172
  snapshot[path] = (
156
173
  "symlink",
157
174
  mode,
@@ -205,12 +222,16 @@ def _snapshot_git_control_metadata(context: RuntimeContext) -> MetadataSnapshot:
205
222
 
206
223
  def excluded(path: pathlib.Path) -> bool:
207
224
  lexical = pathlib.Path(os.path.abspath(path))
208
- return any(is_path_within(lexical, namespace) for namespace in excluded_namespaces)
225
+ return any(
226
+ is_path_within(lexical, namespace) for namespace in excluded_namespaces
227
+ )
209
228
 
210
229
  def record(key: str, path: pathlib.Path) -> None:
211
230
  budget["entries"] += 1
212
231
  if budget["entries"] > METADATA_MAX_FILES:
213
- raise HookError("Git control metadata exceeds the bounded safety snapshot budget")
232
+ raise HookError(
233
+ "Git control metadata exceeds the bounded safety snapshot budget"
234
+ )
214
235
  if path_is_link_or_reparse(path):
215
236
  raise HookError(
216
237
  f"Refusing symlinked monitored Git metadata or reparse point: {key} ({path})"
@@ -282,7 +303,13 @@ def _snapshot_git_control_metadata(context: RuntimeContext) -> MetadataSnapshot:
282
303
  "refs",
283
304
  "worktrees",
284
305
  },
285
- skipped_root_files={"HEAD", "config", "config.worktree", "index", "packed-refs"},
306
+ skipped_root_files={
307
+ "HEAD",
308
+ "config",
309
+ "config.worktree",
310
+ "index",
311
+ "packed-refs",
312
+ },
286
313
  )
287
314
  record("current:logs/HEAD", common_dir / "logs" / "HEAD")
288
315
  else:
@@ -318,11 +345,15 @@ def _git_index_state(repo_root: pathlib.Path) -> tuple[str, str]:
318
345
  staged = run_command(
319
346
  ["git", "ls-files", "--stage", "-z"], cwd=repo_root, check=True
320
347
  ).stdout
321
- flags = run_command(["git", "ls-files", "-v", "-z"], cwd=repo_root, check=True).stdout
348
+ flags = run_command(
349
+ ["git", "ls-files", "-v", "-z"], cwd=repo_root, check=True
350
+ ).stdout
322
351
  return staged, flags
323
352
 
324
353
 
325
- def _validate_apply_allowlist(repo_root: pathlib.Path, patterns: tuple[str, ...]) -> None:
354
+ def _validate_apply_allowlist(
355
+ repo_root: pathlib.Path, patterns: tuple[str, ...]
356
+ ) -> None:
326
357
  for pattern in patterns:
327
358
  parts = relative_path_parts(pattern, "Apply allow_paths entry")
328
359
  if any(normalized_component(part) == PROTECTED_GIT_COMPONENT for part in parts):
@@ -337,7 +368,9 @@ def _validate_apply_allowlist(repo_root: pathlib.Path, patterns: tuple[str, ...]
337
368
  if len(static_parts) == len(parts):
338
369
  candidates = [repo_root.joinpath(*parts)]
339
370
  else:
340
- candidates = [repo_root.joinpath(*static_parts)] if static_parts else [repo_root]
371
+ candidates = (
372
+ [repo_root.joinpath(*static_parts)] if static_parts else [repo_root]
373
+ )
341
374
  for candidate in candidates:
342
375
  if path_has_symlink(repo_root, candidate):
343
376
  raise HookError(f"Apply allow_paths traverses a symlink: {pattern}")
@@ -392,7 +425,9 @@ def _copy_checkout_to_staging(
392
425
  )
393
426
  resolved_source = source.resolve(strict=False)
394
427
  if not is_path_within(resolved_source, resolved_repo_root):
395
- raise HookError(f"Allowed checkout source escapes repository: {relative_path}")
428
+ raise HookError(
429
+ f"Allowed checkout source escapes repository: {relative_path}"
430
+ )
396
431
  if any(is_path_within(resolved_source, root) for root in git_roots):
397
432
  continue
398
433
  if not source.exists():
@@ -400,15 +435,21 @@ def _copy_checkout_to_staging(
400
435
  if not stat.S_ISREG(source.lstat().st_mode):
401
436
  if project_access == "project":
402
437
  continue
403
- raise HookError(f"Allowed checkout path is not a regular file: {relative_path}")
438
+ raise HookError(
439
+ f"Allowed checkout path is not a regular file: {relative_path}"
440
+ )
404
441
  copied_files += 1
405
442
  if copied_files > STAGING_MAX_FILES:
406
- raise HookError("Allowed apply files exceed the bounded staging workspace budget")
443
+ raise HookError(
444
+ "Allowed apply files exceed the bounded staging workspace budget"
445
+ )
407
446
  remaining_bytes = STAGING_MAX_BYTES - copied_bytes
408
447
  content, source_mode = _read_regular_file(source, max_bytes=remaining_bytes)
409
448
  copied_bytes += len(content)
410
449
  if copied_bytes > STAGING_MAX_BYTES:
411
- raise HookError("Allowed apply files exceed the bounded staging workspace budget")
450
+ raise HookError(
451
+ "Allowed apply files exceed the bounded staging workspace budget"
452
+ )
412
453
  baselines[relative_path] = DestinationState(
413
454
  "file",
414
455
  source_mode,
@@ -427,7 +468,9 @@ def _inventory_staging(staging_root: pathlib.Path) -> dict[str, StagedFile]:
427
468
  for directory, dirnames, filenames in os.walk(staging_root, followlinks=False):
428
469
  total_entries += len(dirnames) + len(filenames)
429
470
  if total_entries > STAGING_MAX_FILES:
430
- raise HookError("Apply staging workspace exceeds its bounded inventory budget")
471
+ raise HookError(
472
+ "Apply staging workspace exceeds its bounded inventory budget"
473
+ )
431
474
  directory_path = pathlib.Path(directory)
432
475
  for name in dirnames:
433
476
  path = directory_path / name
@@ -445,10 +488,14 @@ def _inventory_staging(staging_root: pathlib.Path) -> dict[str, StagedFile]:
445
488
  f"Apply staging workspace contains symlink or reparse point: {relative}"
446
489
  )
447
490
  if not stat.S_ISREG(metadata.st_mode):
448
- raise HookError(f"Apply staging workspace contains non-regular file: {relative}")
491
+ raise HookError(
492
+ f"Apply staging workspace contains non-regular file: {relative}"
493
+ )
449
494
  total_bytes += metadata.st_size
450
495
  if len(inventory) >= STAGING_MAX_FILES or total_bytes > STAGING_MAX_BYTES:
451
- raise HookError("Apply staging workspace exceeds its bounded inventory budget")
496
+ raise HookError(
497
+ "Apply staging workspace exceeds its bounded inventory budget"
498
+ )
452
499
  inventory[relative] = StagedFile(
453
500
  _hash_file(path, metadata.st_size), metadata.st_mode, metadata.st_size
454
501
  )
@@ -471,7 +518,10 @@ def _changed_staging_paths(
471
518
  )
472
519
  )
473
520
  if unexpected:
474
- raise HookError("Apply staging workspace contains paths outside allowlist: " + ", ".join(unexpected))
521
+ raise HookError(
522
+ "Apply staging workspace contains paths outside allowlist: "
523
+ + ", ".join(unexpected)
524
+ )
475
525
  return {path for path in all_paths if before.get(path) != after.get(path)}
476
526
 
477
527
 
@@ -506,7 +556,9 @@ def _ignored_changed_paths(
506
556
  def _safe_destination(context: RuntimeContext, relative_path: str) -> pathlib.Path:
507
557
  repo_root = context.repo_root.resolve(strict=True)
508
558
  if _is_protected_path(relative_path):
509
- raise HookError(f"Apply destination must not contain Git metadata: {relative_path}")
559
+ raise HookError(
560
+ f"Apply destination must not contain Git metadata: {relative_path}"
561
+ )
510
562
  destination = _repo_path_from_git(repo_root, relative_path)
511
563
  if path_has_symlink(repo_root, destination):
512
564
  raise HookError(f"Apply destination is or traverses a symlink: {relative_path}")
@@ -514,8 +566,12 @@ def _safe_destination(context: RuntimeContext, relative_path: str) -> pathlib.Pa
514
566
  while not existing_parent.exists() and existing_parent != repo_root:
515
567
  existing_parent = existing_parent.parent
516
568
  if not existing_parent.is_dir():
517
- raise HookError(f"Apply destination has a non-directory parent: {relative_path}")
518
- if not is_path_within(existing_parent.resolve(strict=True), repo_root.resolve(strict=True)):
569
+ raise HookError(
570
+ f"Apply destination has a non-directory parent: {relative_path}"
571
+ )
572
+ if not is_path_within(
573
+ existing_parent.resolve(strict=True), repo_root.resolve(strict=True)
574
+ ):
519
575
  raise HookError(f"Apply destination escapes repository: {relative_path}")
520
576
  resolved_destination = destination.resolve(strict=False)
521
577
  git_roots = (
@@ -523,7 +579,9 @@ def _safe_destination(context: RuntimeContext, relative_path: str) -> pathlib.Pa
523
579
  resolve_git_common_dir(context.repo_root).resolve(strict=True),
524
580
  )
525
581
  if any(is_path_within(resolved_destination, root) for root in git_roots):
526
- raise HookError(f"Apply destination resolves inside Git metadata: {relative_path}")
582
+ raise HookError(
583
+ f"Apply destination resolves inside Git metadata: {relative_path}"
584
+ )
527
585
  return destination
528
586
 
529
587
 
@@ -547,16 +605,23 @@ def _preflight_apply_operations(
547
605
  conflicts = [
548
606
  operation.relative_path
549
607
  for operation in operations
550
- if _snapshot_destination(context.repo_root, operation.relative_path) != operation.baseline
608
+ if _snapshot_destination(context.repo_root, operation.relative_path)
609
+ != operation.baseline
551
610
  ]
552
611
  if conflicts:
553
612
  raise HookError(
554
- "Apply checkout changed concurrently; refusing to overwrite: " + ", ".join(conflicts)
613
+ "Apply checkout changed concurrently; refusing to overwrite: "
614
+ + ", ".join(conflicts)
555
615
  )
556
616
 
557
617
 
558
- def _verify_operation_baseline(context: RuntimeContext, operation: ApplyOperation) -> None:
559
- if _snapshot_destination(context.repo_root, operation.relative_path) != operation.baseline:
618
+ def _verify_operation_baseline(
619
+ context: RuntimeContext, operation: ApplyOperation
620
+ ) -> None:
621
+ if (
622
+ _snapshot_destination(context.repo_root, operation.relative_path)
623
+ != operation.baseline
624
+ ):
560
625
  raise HookError(
561
626
  "Apply checkout changed concurrently; refusing to overwrite: "
562
627
  + operation.relative_path
@@ -575,7 +640,10 @@ def _propagate_staging_changes(
575
640
  _ignored_changed_paths(context.repo_root, changed_paths, work_tree=staging_root)
576
641
  )
577
642
  if ignored:
578
- raise HookError("Refusing to copy staging output to ignored paths: " + ", ".join(sorted(ignored)))
643
+ raise HookError(
644
+ "Refusing to copy staging output to ignored paths: "
645
+ + ", ".join(sorted(ignored))
646
+ )
579
647
  operations: list[ApplyOperation] = []
580
648
  expected: dict[str, StagedFile | None] = {}
581
649
  for relative_path in sorted(changed_paths):
@@ -598,12 +666,13 @@ def _propagate_staging_changes(
598
666
  if not is_path_within(
599
667
  source.resolve(strict=True), staging_root.resolve(strict=True)
600
668
  ):
601
- raise HookError(f"Refusing staged output that escapes workspace: {relative_path}")
669
+ raise HookError(
670
+ f"Refusing staged output that escapes workspace: {relative_path}"
671
+ )
602
672
  content, source_mode = _read_regular_file(source, max_bytes=staged.size)
603
- if (
604
- hashlib.sha256(content).hexdigest() != staged.digest
605
- or stat.S_IMODE(source_mode) != stat.S_IMODE(staged.mode)
606
- ):
673
+ if hashlib.sha256(content).hexdigest() != staged.digest or stat.S_IMODE(
674
+ source_mode
675
+ ) != stat.S_IMODE(staged.mode):
607
676
  raise HookError(f"Staged output changed after validation: {relative_path}")
608
677
  approved_mode = _conservative_propagation_mode(baseline, staged.mode)
609
678
  operations.append(
@@ -649,13 +718,14 @@ def _verify_propagated_changes(
649
718
  mismatches.append(relative_path)
650
719
  continue
651
720
  metadata = destination.lstat()
652
- if _hash_file(destination, staged.size) != staged.digest or stat.S_IMODE(metadata.st_mode) != stat.S_IMODE(
653
- staged.mode
654
- ):
721
+ if _hash_file(destination, staged.size) != staged.digest or stat.S_IMODE(
722
+ metadata.st_mode
723
+ ) != stat.S_IMODE(staged.mode):
655
724
  mismatches.append(relative_path)
656
725
  if mismatches:
657
726
  raise HookError(
658
- "Real checkout does not match validated staging output: " + ", ".join(mismatches)
727
+ "Real checkout does not match validated staging output: "
728
+ + ", ".join(mismatches)
659
729
  )
660
730
 
661
731
 
@@ -815,7 +885,7 @@ def run_apply_step(
815
885
  input_paths: list[pathlib.Path],
816
886
  stage_name: str,
817
887
  ) -> dict[str, object]:
818
- validated_inputs = validate_opencode_attachments(context, input_paths)
888
+ validated_inputs = validate_hook_owned_artifacts(context, input_paths)
819
889
  for input_path in validated_inputs:
820
890
  if input_path.name.endswith("issues.json"):
821
891
  issues = json.loads(input_path.read_text(encoding="utf-8"))
@@ -834,7 +904,9 @@ def run_apply_step(
834
904
  call_error: Exception | None = None
835
905
  staged_changes: set[str] = set()
836
906
  propagated_expected: dict[str, StagedFile | None] = {}
837
- with tempfile.TemporaryDirectory(prefix="ai-push-hooks-apply-") as temporary_directory:
907
+ with tempfile.TemporaryDirectory(
908
+ prefix="ai-push-hooks-apply-"
909
+ ) as temporary_directory:
838
910
  staging_root = pathlib.Path(temporary_directory).resolve(strict=True)
839
911
  profile = resolve_runner_profile(context.config, step)
840
912
  destination_baselines = _copy_checkout_to_staging(
@@ -877,11 +949,17 @@ def run_apply_step(
877
949
  metadata_before,
878
950
  )
879
951
  if call_error is not None:
880
- raise HookError(f"Apply step failed in isolated staging: {call_error}") from call_error
952
+ raise HookError(
953
+ f"Apply step failed in isolated staging: {call_error}"
954
+ ) from call_error
881
955
  if result is None:
882
956
  raise HookError("Apply step failed without a runner result")
883
957
  if result.returncode != 0:
884
- details = result.stderr.strip() or result.stdout.strip() or f"exit code {result.returncode}"
958
+ details = (
959
+ result.stderr.strip()
960
+ or result.stdout.strip()
961
+ or f"exit code {result.returncode}"
962
+ )
885
963
  raise HookError(f"Apply step failed in isolated staging: {details}")
886
964
  propagated_expected = _propagate_staging_changes(
887
965
  context,