ai-push-hooks 0.2.1 → 0.3.1

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 (37) hide show
  1. package/CHANGELOG.md +73 -1
  2. package/README.md +80 -525
  3. package/SECURITY.md +102 -14
  4. package/ai-push-hooks.toml +9 -2
  5. package/bin/ai-push-hooks.js +6 -6
  6. package/package.json +3 -2
  7. package/pyproject.toml +1 -1
  8. package/src/ai_push_hooks/artifacts.py +67 -13
  9. package/src/ai_push_hooks/config.py +575 -22
  10. package/src/ai_push_hooks/engine.py +116 -7
  11. package/src/ai_push_hooks/executors/apply.py +75 -36
  12. package/src/ai_push_hooks/executors/ask.py +224 -0
  13. package/src/ai_push_hooks/executors/exec.py +17 -801
  14. package/src/ai_push_hooks/executors/runner_workflow.py +478 -0
  15. package/src/ai_push_hooks/executors/runners/__init__.py +78 -0
  16. package/src/ai_push_hooks/executors/runners/claude.py +286 -0
  17. package/src/ai_push_hooks/executors/runners/codex.py +254 -0
  18. package/src/ai_push_hooks/executors/runners/command.py +178 -0
  19. package/src/ai_push_hooks/executors/runners/contracts.py +597 -0
  20. package/src/ai_push_hooks/executors/runners/opencode.py +528 -0
  21. package/src/ai_push_hooks/executors/runners/opencode_support.py +276 -0
  22. package/src/ai_push_hooks/executors/runners/process.py +464 -0
  23. package/src/ai_push_hooks/executors/runners/registry.py +117 -0
  24. package/src/ai_push_hooks/executors/step_commands.py +478 -0
  25. package/src/ai_push_hooks/git_utils.py +834 -0
  26. package/src/ai_push_hooks/hook.py +1 -1
  27. package/src/ai_push_hooks/modules/beads.py +1 -1
  28. package/src/ai_push_hooks/modules/docs.py +129 -89
  29. package/src/ai_push_hooks/modules/pr.py +1 -1
  30. package/src/ai_push_hooks/plugin_loader.py +422 -0
  31. package/src/ai_push_hooks/plugins.py +134 -0
  32. package/src/ai_push_hooks/prompts_builtin.py +9 -2
  33. package/src/ai_push_hooks/types.py +407 -75
  34. package/vendor/README.md +15 -0
  35. package/vendor/requirements.txt +1 -0
  36. package/vendor/tomli-2.4.0-py3-none-any.whl +0 -0
  37. package/src/ai_push_hooks/executors/llm.py +0 -624
@@ -0,0 +1,528 @@
1
+ """OpenCode adapter for the internal runner boundary.
2
+
3
+ The adapter deliberately retains the shipped ``opencode run`` shape. This
4
+ module owns the adapter-level protocol and lifecycle semantics; workflow
5
+ retries and JSON/schema handling remain in orchestration.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import pathlib
12
+ import shutil
13
+ import sys
14
+ import tempfile
15
+ from typing import Any
16
+
17
+ from .opencode_support import (
18
+ OPENCODE_APPLY_AGENT,
19
+ OPENCODE_READ_ONLY_AGENT,
20
+ _transcript_dir,
21
+ build_opencode_security_config,
22
+ non_agent_opencode_config,
23
+ opencode_isolation_env,
24
+ resolve_opencode_executable,
25
+ sanitize_filename_component,
26
+ validate_hook_owned_artifacts,
27
+ )
28
+ from ...paths import ensure_private_directory, resolve_contained_path, write_text_no_follow
29
+ from .process import ProcessResult
30
+ from .contracts import (
31
+ RunnerCapabilities,
32
+ RunnerContractError,
33
+ RunnerError,
34
+ RunnerMissingOutputError,
35
+ RunnerProtocolError,
36
+ RunnerRequest,
37
+ RunnerResult,
38
+ SessionMetadata,
39
+ bounded_diagnostic,
40
+ require_final_text,
41
+ require_zero_exit,
42
+ )
43
+ from .process import run_process
44
+
45
+
46
+ class OpenCodeRunner:
47
+ """Run one isolated OpenCode invocation and optionally finalize its session."""
48
+
49
+ capabilities = RunnerCapabilities(
50
+ supports_resume=True,
51
+ supports_finalize=True,
52
+ supports_transcript=True,
53
+ )
54
+
55
+ def _context(self, request: RunnerRequest) -> Any:
56
+ context = request.integration_context
57
+ if context is None:
58
+ raise RunnerContractError(
59
+ "OpenCode runner requires an integration context for executable and isolation policy"
60
+ )
61
+ return context
62
+
63
+ @staticmethod
64
+ def _parse_output(raw: str, request: RunnerRequest) -> tuple[str | None, str]:
65
+ session_id: str | None = None
66
+ text_parts: list[str] = []
67
+
68
+ def protocol_failure(message: str, *, missing: bool = False) -> RunnerProtocolError:
69
+ error: RunnerProtocolError
70
+ if missing:
71
+ error = RunnerMissingOutputError(message)
72
+ else:
73
+ error = RunnerProtocolError(message)
74
+ # A session may have been announced before a later malformed or
75
+ # error event. Keep that identity on every protocol failure so
76
+ # the orchestrator can still finalize it.
77
+ known_session = session_id
78
+ if known_session is None and request.resume_session:
79
+ known_session = request.session_id
80
+ setattr(error, "session_id", known_session)
81
+ return error
82
+
83
+ for line in raw.splitlines():
84
+ if not line.strip():
85
+ continue
86
+ try:
87
+ event = json.loads(line)
88
+ except json.JSONDecodeError as exc:
89
+ raise protocol_failure("OpenCode emitted malformed JSONL output") from exc
90
+ if not isinstance(event, dict):
91
+ raise protocol_failure("OpenCode emitted a non-object JSONL event")
92
+
93
+ event_type = event.get("type")
94
+
95
+ for key in ("sessionID", "session_id"):
96
+ value = event.get(key)
97
+ if session_id is None and isinstance(value, str) and value.strip():
98
+ session_id = value.strip()
99
+
100
+ if event_type in {"error", "fatal", "session.error"} or (
101
+ isinstance(event.get("error"), (str, dict, list))
102
+ and event_type not in {"text", "step_finish"}
103
+ ):
104
+ # Do not serialize the provider event: OpenCode may echo the
105
+ # instruction, artifact bodies, or credential-shaped data.
106
+ raise protocol_failure("OpenCode reported an error event")
107
+
108
+ if event_type != "text":
109
+ # New OpenCode event kinds are additive unless they are an
110
+ # explicit error. Only text parts form the final response.
111
+ continue
112
+ part = event.get("part")
113
+ if not isinstance(part, dict):
114
+ raise protocol_failure("OpenCode text event has no text part")
115
+ text = part.get("text")
116
+ if not isinstance(text, str):
117
+ raise protocol_failure("OpenCode text event has an invalid text value")
118
+ text_parts.append(text)
119
+
120
+ final_text = "\n".join(text_parts).strip()
121
+ if not final_text:
122
+ raise protocol_failure("OpenCode produced no final response", missing=True)
123
+ return session_id, final_text
124
+
125
+ @staticmethod
126
+ def _session_id_from_partial_output(raw: str) -> str | None:
127
+ """Recover only a complete session identifier from a bounded prefix."""
128
+
129
+ for line in raw.splitlines():
130
+ try:
131
+ event = json.loads(line)
132
+ except json.JSONDecodeError:
133
+ continue
134
+ if not isinstance(event, dict):
135
+ continue
136
+ for key in ("sessionID", "session_id"):
137
+ value = event.get(key)
138
+ if isinstance(value, str) and value.strip():
139
+ return value.strip()
140
+ return None
141
+
142
+ @staticmethod
143
+ def _attachment_name(index: int, logical_name: str) -> str:
144
+ # The order prefix prevents collisions after sanitizing path-like
145
+ # logical names while retaining a useful basename for CLI diagnostics.
146
+ basename = pathlib.PurePath(logical_name.replace("\\", "/")).name
147
+ safe_basename = sanitize_filename_component(basename)
148
+ if safe_basename in {"", ".", ".."}:
149
+ safe_basename = "artifact"
150
+ return f"{index:04d}-{safe_basename}"
151
+
152
+ def _materialize_attachments(
153
+ self,
154
+ context: Any,
155
+ request: RunnerRequest,
156
+ ) -> tuple[list[pathlib.Path], pathlib.Path | None]:
157
+ original_paths = [artifact.path for artifact in request.artifacts]
158
+ # Validate source ownership and symlink traversal before making any
159
+ # copy. The source is then never reopened: its logical snapshot is
160
+ # the only content sent through OpenCode's native --file transport.
161
+ validate_hook_owned_artifacts(
162
+ context,
163
+ [path for path in original_paths if path is not None],
164
+ )
165
+ if not request.artifacts:
166
+ return [], None
167
+
168
+ run_root = ensure_private_directory(context.run_dir.resolve(strict=True))
169
+ attachment_dir = pathlib.Path(
170
+ tempfile.mkdtemp(prefix="opencode-attachments-", dir=str(run_root))
171
+ )
172
+ try:
173
+ ensure_private_directory(attachment_dir, private_root=run_root)
174
+ paths: list[pathlib.Path] = []
175
+ for index, artifact in enumerate(request.artifacts, start=1):
176
+ target = resolve_contained_path(
177
+ attachment_dir,
178
+ self._attachment_name(index, artifact.name),
179
+ "OpenCode materialized attachment path",
180
+ )
181
+ write_text_no_follow(target, artifact.content)
182
+ paths.append(target)
183
+ return paths, attachment_dir
184
+ except Exception:
185
+ shutil.rmtree(attachment_dir, ignore_errors=True)
186
+ raise
187
+
188
+ @staticmethod
189
+ def _cleanup_attachments(
190
+ attachment_dir: pathlib.Path | None,
191
+ *,
192
+ session_id: str | None = None,
193
+ ) -> None:
194
+ if attachment_dir is None:
195
+ return
196
+ # Capture the active exception before entering the cleanup try block;
197
+ # sys.exc_info() inside ``except OSError`` refers to the cleanup error,
198
+ # not the invocation error being unwound.
199
+ unwinding_error = sys.exc_info()[1]
200
+ try:
201
+ shutil.rmtree(attachment_dir)
202
+ except FileNotFoundError:
203
+ return
204
+ except OSError as exc:
205
+ if unwinding_error is not None:
206
+ if session_id and not getattr(unwinding_error, "session_id", None):
207
+ setattr(unwinding_error, "session_id", session_id)
208
+ return
209
+ error = RunnerError("OpenCode attachment cleanup failed")
210
+ if session_id:
211
+ setattr(error, "session_id", session_id)
212
+ raise error from exc
213
+
214
+ def _argv(
215
+ self,
216
+ request: RunnerRequest,
217
+ context: Any,
218
+ executable: str,
219
+ attachments: list[pathlib.Path],
220
+ *,
221
+ prompt: str,
222
+ ) -> list[str]:
223
+ agent = OPENCODE_APPLY_AGENT if request.mode == "apply" else OPENCODE_READ_ONLY_AGENT
224
+ config = getattr(getattr(context, "config", None), "llm", None)
225
+ title_prefix = getattr(config, "session_title_prefix", "ai-push-hooks")
226
+ argv = [
227
+ executable,
228
+ "run",
229
+ "--agent",
230
+ agent,
231
+ "--pure",
232
+ "--format",
233
+ "json",
234
+ ]
235
+ if request.model:
236
+ argv.extend(["--model", request.model])
237
+ if request.variant:
238
+ argv.extend(["--variant", request.variant])
239
+ if request.resume_session and request.session_id:
240
+ argv.extend(["--session", request.session_id])
241
+ else:
242
+ argv.extend(
243
+ [
244
+ "--title",
245
+ f"{title_prefix} {context.run_id} {request.stage}",
246
+ ]
247
+ )
248
+ for path in attachments:
249
+ argv.extend(["--file", str(path)])
250
+ argv.extend(["--", prompt])
251
+ return argv
252
+
253
+ @staticmethod
254
+ def _require_staging_directory(context: Any, working_directory: pathlib.Path) -> None:
255
+ """Reject repository worktrees as apply targets.
256
+
257
+ The apply workflow supplies a disposable, non-VCS staging projection.
258
+ A runner request can identify the repository root and the selected cwd,
259
+ but it cannot turn an arbitrary host directory into a sandbox. Refuse
260
+ the known unsafe cases at this boundary instead of granting edit
261
+ permissions to the checked-out repository.
262
+ """
263
+
264
+ repository_root = context.repo_root.resolve(strict=True)
265
+ if working_directory == repository_root or working_directory.is_relative_to(repository_root):
266
+ raise RunnerContractError(
267
+ "OpenCode apply requests require an isolated staging directory"
268
+ )
269
+ if any((parent / ".git").exists() for parent in (working_directory, *working_directory.parents)):
270
+ raise RunnerContractError(
271
+ "OpenCode apply requests require a non-VCS isolated staging directory"
272
+ )
273
+
274
+ def run(self, request: RunnerRequest) -> RunnerResult:
275
+ if request.runner_type != "opencode":
276
+ raise RunnerContractError(
277
+ f"OpenCode runner cannot handle runner type {request.runner_type!r}"
278
+ )
279
+ context = self._context(request)
280
+ if request.mode == "apply" and not request.allow_paths:
281
+ raise RunnerContractError("OpenCode apply requests require allow_paths")
282
+ if request.mode == "apply" and request.project_access == "artifacts":
283
+ # Apply always runs in a host-created staging projection, even for
284
+ # the compatibility profile.
285
+ working_directory = request.cwd
286
+ elif request.project_access == "project":
287
+ working_directory = request.cwd
288
+ else:
289
+ working_directory = None
290
+
291
+ if working_directory is not None:
292
+ try:
293
+ working_directory = working_directory.resolve(strict=True)
294
+ except (OSError, RuntimeError) as exc:
295
+ raise RunnerContractError(
296
+ "OpenCode request cwd must be an existing directory"
297
+ ) from exc
298
+ if not working_directory.is_dir():
299
+ raise RunnerContractError("OpenCode request cwd must be an existing directory")
300
+ if request.mode == "apply":
301
+ self._require_staging_directory(context, working_directory)
302
+
303
+ agent = "apply" if request.mode == "apply" else "read-only"
304
+ _agent_name, security_config = build_opencode_security_config(
305
+ agent,
306
+ request.allow_paths,
307
+ non_vcs_working_directory=(
308
+ working_directory if request.mode == "apply" else None
309
+ ),
310
+ project_read_root=(
311
+ working_directory if request.project_access == "project" else None
312
+ ),
313
+ )
314
+ executable = getattr(context, "opencode_executable", None) or resolve_opencode_executable()
315
+ isolated_env = opencode_isolation_env(context, security_config, request.stage)
316
+ attachment_dir: pathlib.Path | None = None
317
+ session_id: str | None = None
318
+ try:
319
+ attachments, attachment_dir = self._materialize_attachments(context, request)
320
+ # Every logical artifact is materialized, including pathless
321
+ # contract artifacts. Therefore native attachments carry the
322
+ # complete snapshot and the instruction is never duplicated in a
323
+ # prompt packet.
324
+ argv = self._argv(
325
+ request,
326
+ context,
327
+ executable,
328
+ attachments,
329
+ prompt=request.instruction,
330
+ )
331
+
332
+ def invoke(cwd: pathlib.Path) -> Any:
333
+ completed = run_process(
334
+ argv,
335
+ cwd=cwd,
336
+ timeout_seconds=request.timeout_seconds,
337
+ env=isolated_env,
338
+ )
339
+ if getattr(completed, "stdout_truncated", False) or getattr(
340
+ completed, "stderr_truncated", False
341
+ ):
342
+ error = RunnerProtocolError("OpenCode process output was truncated")
343
+ setattr(
344
+ error,
345
+ "session_id",
346
+ self._session_id_from_partial_output(completed.stdout)
347
+ or (request.session_id if request.resume_session else None),
348
+ )
349
+ raise error
350
+ return completed
351
+
352
+ try:
353
+ if working_directory is None:
354
+ with tempfile.TemporaryDirectory(prefix="ai-push-hooks-readonly-") as directory:
355
+ completed = invoke(pathlib.Path(directory).resolve(strict=True))
356
+ else:
357
+ completed = invoke(working_directory)
358
+ except (RunnerError,) as error:
359
+ process_result = getattr(error, "_process_result", None)
360
+ partial_stdout = (
361
+ process_result.stdout if isinstance(process_result, ProcessResult) else ""
362
+ )
363
+ setattr(
364
+ error,
365
+ "session_id",
366
+ self._session_id_from_partial_output(partial_stdout)
367
+ or getattr(error, "session_id", None)
368
+ or (request.session_id if request.resume_session else None),
369
+ )
370
+ raise
371
+
372
+ result = RunnerResult(
373
+ final_text="",
374
+ returncode=completed.returncode,
375
+ stdout=completed.stdout,
376
+ stderr=completed.stderr,
377
+ )
378
+ # Check the process status before parsing. A failed child may emit
379
+ # a partial or malformed stream; the process failure is the
380
+ # truthful, normalized terminal diagnosis.
381
+ try:
382
+ require_zero_exit(request, result, env=isolated_env)
383
+ except RunnerError as error:
384
+ setattr(
385
+ error,
386
+ "session_id",
387
+ self._session_id_from_partial_output(completed.stdout)
388
+ or (request.session_id if request.resume_session else None),
389
+ )
390
+ raise
391
+ session_id, final_text = self._parse_output(completed.stdout, request)
392
+ if request.resume_session and session_id is None:
393
+ session_id = request.session_id
394
+ return RunnerResult(
395
+ final_text=require_final_text(final_text, mode=request.mode),
396
+ returncode=completed.returncode,
397
+ stdout=completed.stdout,
398
+ stderr=completed.stderr,
399
+ session=(
400
+ SessionMetadata(session_id=session_id, state="persisted", resumable=True)
401
+ if session_id
402
+ else SessionMetadata()
403
+ ),
404
+ )
405
+ finally:
406
+ active_error = sys.exc_info()[1]
407
+ cleanup_session_id = session_id or getattr(active_error, "session_id", None)
408
+ self._cleanup_attachments(
409
+ attachment_dir,
410
+ session_id=(
411
+ cleanup_session_id if isinstance(cleanup_session_id, str) else None
412
+ ),
413
+ )
414
+
415
+ def _lifecycle_process(
416
+ self,
417
+ request: RunnerRequest,
418
+ context: Any,
419
+ argv: list[str],
420
+ action: str,
421
+ ) -> Any:
422
+ # Export/delete must use the original stage isolation root. Using a
423
+ # different action-specific root makes retained sessions invisible to
424
+ # OpenCode because its HOME/XDG state is isolated per stage.
425
+ env = opencode_isolation_env(
426
+ context,
427
+ non_agent_opencode_config(),
428
+ request.stage,
429
+ )
430
+ with tempfile.TemporaryDirectory(prefix=f"ai-push-hooks-session-{action}-") as directory:
431
+ return run_process(
432
+ argv,
433
+ cwd=pathlib.Path(directory).resolve(strict=True),
434
+ timeout_seconds=request.timeout_seconds,
435
+ env=env,
436
+ )
437
+
438
+ def finalize(self, request: RunnerRequest, result: RunnerResult) -> RunnerResult:
439
+ session = result.session
440
+ if session is None or not session.session_id:
441
+ return result
442
+ context = self._context(request)
443
+ session_id = session.session_id
444
+ transcript_path: pathlib.Path | None = None
445
+
446
+ if getattr(context.config.logging, "capture_llm_transcript", False):
447
+ transcript_dir = _transcript_dir(context)
448
+ if transcript_dir is not None:
449
+ transcript_path = resolve_contained_path(
450
+ transcript_dir,
451
+ (
452
+ f"{sanitize_filename_component(context.run_id)}-"
453
+ f"{sanitize_filename_component(request.stage)}-"
454
+ f"{sanitize_filename_component(session_id)}.json"
455
+ ),
456
+ "OpenCode transcript path",
457
+ )
458
+ try:
459
+ executable = getattr(context, "opencode_executable", None) or resolve_opencode_executable()
460
+ exported = self._lifecycle_process(
461
+ request,
462
+ context,
463
+ [executable, "export", session_id, "--pure"],
464
+ "export",
465
+ )
466
+ if (
467
+ exported.returncode == 0
468
+ and not getattr(exported, "stdout_truncated", False)
469
+ and not getattr(exported, "stderr_truncated", False)
470
+ and exported.stdout.strip()
471
+ ):
472
+ write_text_no_follow(transcript_path, exported.stdout.strip() + "\n")
473
+ else:
474
+ transcript_path = None
475
+ self._warn_export(context, request, session_id, "export returned no transcript")
476
+ except Exception as exc: # noqa: BLE001
477
+ transcript_path = None
478
+ self._warn_export(context, request, session_id, type(exc).__name__)
479
+
480
+ deleted = False
481
+ if getattr(context.config.llm, "delete_session_after_run", False):
482
+ try:
483
+ executable = getattr(context, "opencode_executable", None) or resolve_opencode_executable()
484
+ deleted_result = self._lifecycle_process(
485
+ request,
486
+ context,
487
+ [executable, "session", "delete", session_id, "--pure"],
488
+ "delete",
489
+ )
490
+ deleted = deleted_result.returncode == 0
491
+ if not deleted:
492
+ self._warn_export(context, request, session_id, "session deletion failed")
493
+ except Exception as exc: # noqa: BLE001
494
+ self._warn_export(context, request, session_id, f"delete {type(exc).__name__}")
495
+
496
+ state = "deleted" if deleted else "persisted"
497
+ finalized_session = SessionMetadata(
498
+ session_id=session_id,
499
+ state=state,
500
+ resumable=not deleted,
501
+ transcript=str(transcript_path) if transcript_path is not None else None,
502
+ )
503
+ return RunnerResult(
504
+ final_text=result.final_text,
505
+ returncode=result.returncode,
506
+ stdout=result.stdout,
507
+ stderr=result.stderr,
508
+ session=finalized_session,
509
+ transcript=result.transcript,
510
+ )
511
+
512
+ @staticmethod
513
+ def _warn_export(context: Any, request: RunnerRequest, session_id: str, reason: str) -> None:
514
+ logger = getattr(context, "logger", None)
515
+ if logger is not None:
516
+ logger.warn(
517
+ "llm.transcript_export_failed",
518
+ "Could not capture or delete the OpenCode session cleanly.",
519
+ stage_name=request.stage,
520
+ session_id=session_id,
521
+ reason=bounded_diagnostic(reason, max_chars=120),
522
+ )
523
+
524
+
525
+ def create_runner() -> OpenCodeRunner:
526
+ """Registry factory for the built-in OpenCode adapter."""
527
+
528
+ return OpenCodeRunner()