ai-push-hooks 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +68 -2
- package/README.md +582 -85
- package/SECURITY.md +96 -13
- package/ai-push-hooks.toml +9 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/ai_push_hooks/artifacts.py +67 -0
- package/src/ai_push_hooks/config.py +560 -21
- package/src/ai_push_hooks/engine.py +114 -6
- package/src/ai_push_hooks/executors/apply.py +73 -34
- package/src/ai_push_hooks/executors/{llm.py → ask.py} +197 -75
- package/src/ai_push_hooks/executors/exec.py +9 -1
- package/src/ai_push_hooks/executors/runner_workflow.py +478 -0
- package/src/ai_push_hooks/executors/runners/__init__.py +78 -0
- package/src/ai_push_hooks/executors/runners/claude.py +286 -0
- package/src/ai_push_hooks/executors/runners/codex.py +254 -0
- package/src/ai_push_hooks/executors/runners/command.py +178 -0
- package/src/ai_push_hooks/executors/runners/contracts.py +597 -0
- package/src/ai_push_hooks/executors/runners/opencode.py +499 -0
- package/src/ai_push_hooks/executors/runners/process.py +403 -0
- package/src/ai_push_hooks/executors/runners/registry.py +104 -0
- package/src/ai_push_hooks/executors/step_commands.py +473 -0
- package/src/ai_push_hooks/plugin_loader.py +398 -0
- package/src/ai_push_hooks/plugins.py +134 -0
- package/src/ai_push_hooks/prompts_builtin.py +9 -2
- package/src/ai_push_hooks/types.py +406 -75
|
@@ -17,11 +17,16 @@ from ..paths import (
|
|
|
17
17
|
write_text_no_follow,
|
|
18
18
|
)
|
|
19
19
|
from ..types import HookError, RuntimeContext, StepConfig
|
|
20
|
-
from .exec import ensure_dir,
|
|
20
|
+
from .exec import ensure_dir, resolve_storage_path, run_command
|
|
21
21
|
|
|
22
22
|
OPENCODE_READ_ONLY_AGENT = "ai-push-hooks-readonly"
|
|
23
23
|
OPENCODE_APPLY_AGENT = "ai-push-hooks-apply"
|
|
24
24
|
OPENCODE_AGENT_POLICIES = frozenset({"read-only", "apply"})
|
|
25
|
+
# A sentinel keeps the legacy helper's implicit flat-config behavior while
|
|
26
|
+
# allowing the runner boundary to explicitly pass ``None`` and omit optional
|
|
27
|
+
# OpenCode flags.
|
|
28
|
+
_DEFAULT_MODEL = object()
|
|
29
|
+
_DEFAULT_VARIANT = object()
|
|
25
30
|
PROVIDER_ENV_PREFIXES = (
|
|
26
31
|
"ANTHROPIC_",
|
|
27
32
|
"AWS_",
|
|
@@ -100,7 +105,6 @@ def opencode_isolation_env(
|
|
|
100
105
|
"OPENCODE_DISABLE_CLAUDE_CODE": "true",
|
|
101
106
|
"OPENCODE_DISABLE_CLAUDE_CODE_PROMPT": "true",
|
|
102
107
|
"OPENCODE_DISABLE_CLAUDE_CODE_SKILLS": "true",
|
|
103
|
-
"OPENCODE_DISABLE_DEFAULT_PLUGINS": "true",
|
|
104
108
|
"OPENCODE_DISABLE_LSP_DOWNLOAD": "true",
|
|
105
109
|
"OPENCODE_DISABLE_SHARE": "true",
|
|
106
110
|
"OPENCODE_DISABLE_AUTOUPDATE": "true",
|
|
@@ -271,11 +275,11 @@ def export_opencode_session_json(
|
|
|
271
275
|
return True
|
|
272
276
|
|
|
273
277
|
|
|
274
|
-
def delete_opencode_session(context: RuntimeContext, session_id: str) ->
|
|
278
|
+
def delete_opencode_session(context: RuntimeContext, session_id: str) -> bool:
|
|
275
279
|
with tempfile.TemporaryDirectory(
|
|
276
280
|
prefix="ai-push-hooks-session-delete-"
|
|
277
281
|
) as temporary_directory:
|
|
278
|
-
run_command(
|
|
282
|
+
completed = run_command(
|
|
279
283
|
[
|
|
280
284
|
context.opencode_executable or resolve_opencode_executable(),
|
|
281
285
|
"session",
|
|
@@ -289,6 +293,7 @@ def delete_opencode_session(context: RuntimeContext, session_id: str) -> None:
|
|
|
289
293
|
env=opencode_isolation_env(context, non_agent_opencode_config(), "session-delete"),
|
|
290
294
|
inherit_env=False,
|
|
291
295
|
)
|
|
296
|
+
return completed.returncode == 0
|
|
292
297
|
|
|
293
298
|
|
|
294
299
|
def finalize_opencode_session(context: RuntimeContext, stage_name: str, session_id: str | None) -> None:
|
|
@@ -335,6 +340,7 @@ def build_opencode_security_config(
|
|
|
335
340
|
allow_paths: tuple[str, ...] = (),
|
|
336
341
|
*,
|
|
337
342
|
non_vcs_working_directory: pathlib.Path | None = None,
|
|
343
|
+
project_read_root: pathlib.Path | None = None,
|
|
338
344
|
) -> tuple[str, dict[str, Any]]:
|
|
339
345
|
permissions: dict[str, Any] = {
|
|
340
346
|
"*": "deny",
|
|
@@ -352,6 +358,7 @@ def build_opencode_security_config(
|
|
|
352
358
|
"skill": "deny",
|
|
353
359
|
"todowrite": "deny",
|
|
354
360
|
"question": "deny",
|
|
361
|
+
"doom_loop": "deny",
|
|
355
362
|
}
|
|
356
363
|
if agent_policy == "read-only":
|
|
357
364
|
agent_name = OPENCODE_READ_ONLY_AGENT
|
|
@@ -396,6 +403,31 @@ def build_opencode_security_config(
|
|
|
396
403
|
else:
|
|
397
404
|
raise HookError(f"Unsupported OpenCode agent policy: {agent_policy}")
|
|
398
405
|
|
|
406
|
+
if project_read_root is not None:
|
|
407
|
+
# OpenCode's file permission matcher uses paths relative to its
|
|
408
|
+
# filesystem worktree. A real Git checkout is already rooted at the
|
|
409
|
+
# requested worktree, so absolute filesystem-root-relative patterns
|
|
410
|
+
# would never match README.md-style tool paths. A non-VCS projection
|
|
411
|
+
# is treated as a filesystem worktree rooted at `/`, so qualify those
|
|
412
|
+
# patterns with the projection's absolute prefix.
|
|
413
|
+
root = project_read_root.resolve(strict=False)
|
|
414
|
+
if (root / ".git").exists():
|
|
415
|
+
rooted_permissions: str | dict[str, str] = "allow"
|
|
416
|
+
else:
|
|
417
|
+
anchor = pathlib.Path(root.anchor)
|
|
418
|
+
prefix = root.relative_to(anchor).as_posix()
|
|
419
|
+
rooted_permissions = {
|
|
420
|
+
"*": "deny",
|
|
421
|
+
prefix: "allow",
|
|
422
|
+
f"{prefix}/**": "allow",
|
|
423
|
+
}
|
|
424
|
+
for tool in ("read", "list", "glob", "grep"):
|
|
425
|
+
permissions[tool] = (
|
|
426
|
+
dict(rooted_permissions)
|
|
427
|
+
if isinstance(rooted_permissions, dict)
|
|
428
|
+
else rooted_permissions
|
|
429
|
+
)
|
|
430
|
+
|
|
399
431
|
return agent_name, {
|
|
400
432
|
"$schema": "https://opencode.ai/config.json",
|
|
401
433
|
"plugin": [],
|
|
@@ -447,6 +479,9 @@ def call_opencode(
|
|
|
447
479
|
attempt: int | None = None,
|
|
448
480
|
total_attempts: int | None = None,
|
|
449
481
|
existing_session_id: str | None = None,
|
|
482
|
+
model: str | None | object = _DEFAULT_MODEL,
|
|
483
|
+
variant: str | None | object = _DEFAULT_VARIANT,
|
|
484
|
+
project_access: str = "artifacts",
|
|
450
485
|
) -> OpenCodeRunResult:
|
|
451
486
|
"""Run OpenCode with a policy-specific isolated working directory.
|
|
452
487
|
|
|
@@ -462,6 +497,10 @@ def call_opencode(
|
|
|
462
497
|
raise HookError("OpenCode apply agent requires an isolated staging directory")
|
|
463
498
|
if agent == "read-only" and allow_paths:
|
|
464
499
|
raise HookError("OpenCode read-only agent does not accept write paths")
|
|
500
|
+
if project_access not in {"artifacts", "project"}:
|
|
501
|
+
raise HookError(f"Unsupported OpenCode project access: {project_access}")
|
|
502
|
+
if project_access == "project" and working_directory is None:
|
|
503
|
+
raise HookError("OpenCode project access requires an explicit working directory")
|
|
465
504
|
|
|
466
505
|
validated_files = validate_opencode_attachments(context, files)
|
|
467
506
|
if working_directory is None:
|
|
@@ -483,9 +522,18 @@ def call_opencode(
|
|
|
483
522
|
non_vcs_working_directory=(
|
|
484
523
|
resolved_working_directory if agent == "apply" else None
|
|
485
524
|
),
|
|
525
|
+
project_read_root=(
|
|
526
|
+
resolved_working_directory if project_access == "project" else None
|
|
527
|
+
),
|
|
486
528
|
)
|
|
487
529
|
executable = context.opencode_executable or resolve_opencode_executable()
|
|
488
|
-
|
|
530
|
+
effective_model = (
|
|
531
|
+
context.config.llm.model if model is _DEFAULT_MODEL else model
|
|
532
|
+
)
|
|
533
|
+
effective_variant = (
|
|
534
|
+
context.config.llm.variant if variant is _DEFAULT_VARIANT else variant
|
|
535
|
+
)
|
|
536
|
+
context.logger.llm_call(stage_name, purpose, effective_model or "", attempt, total_attempts)
|
|
489
537
|
isolated_env = opencode_isolation_env(context, security_config, stage_name)
|
|
490
538
|
cmd = [
|
|
491
539
|
executable,
|
|
@@ -495,11 +543,11 @@ def call_opencode(
|
|
|
495
543
|
"--pure",
|
|
496
544
|
"--format",
|
|
497
545
|
"json",
|
|
498
|
-
"--model",
|
|
499
|
-
context.config.llm.model,
|
|
500
546
|
]
|
|
501
|
-
if
|
|
502
|
-
cmd.extend(["--
|
|
547
|
+
if effective_model:
|
|
548
|
+
cmd.extend(["--model", effective_model])
|
|
549
|
+
if effective_variant:
|
|
550
|
+
cmd.extend(["--variant", effective_variant])
|
|
503
551
|
if existing_session_id:
|
|
504
552
|
cmd.extend(["--session", existing_session_id])
|
|
505
553
|
else:
|
|
@@ -530,8 +578,11 @@ def call_opencode(
|
|
|
530
578
|
session_id, text_output = parse_opencode_json_run_output(completed.stdout or "")
|
|
531
579
|
stdout = completed.stdout or ""
|
|
532
580
|
stderr = completed.stderr or ""
|
|
533
|
-
if context.config.logging.print_llm_output and
|
|
534
|
-
|
|
581
|
+
if context.config.logging.print_llm_output and text_output:
|
|
582
|
+
# Print normalized assistant text only. OpenCode's JSONL stream can
|
|
583
|
+
# contain provider diagnostics and tool payloads which must not become
|
|
584
|
+
# an accidental credential/prompt log.
|
|
585
|
+
print(text_output)
|
|
535
586
|
return OpenCodeRunResult(
|
|
536
587
|
output_text=text_output if text_output else stdout.strip(),
|
|
537
588
|
session_id=session_id or existing_session_id,
|
|
@@ -541,85 +592,156 @@ def call_opencode(
|
|
|
541
592
|
)
|
|
542
593
|
|
|
543
594
|
|
|
544
|
-
def
|
|
595
|
+
def _safe_invalid_output(invocation: Any, output: str) -> str:
|
|
596
|
+
from .runners.contracts import request_sensitive_diagnostics
|
|
597
|
+
|
|
598
|
+
request = invocation.request
|
|
599
|
+
return request_sensitive_diagnostics(request, output, max_chars=400, env=os.environ)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def run_ask_step(
|
|
545
603
|
context: RuntimeContext,
|
|
546
604
|
step: StepConfig,
|
|
547
605
|
prompt: str,
|
|
548
606
|
input_paths: list[pathlib.Path],
|
|
549
607
|
stage_name: str,
|
|
550
608
|
) -> Any:
|
|
609
|
+
from .runner_workflow import _finalize_invocation, _invoke_runner, _named_error
|
|
610
|
+
from ..config import resolve_runner_profile
|
|
611
|
+
|
|
551
612
|
total_attempts = context.config.llm.json_max_retries + 1
|
|
552
|
-
session_id: str | None = None
|
|
553
613
|
prompt_text = prompt
|
|
554
614
|
last_error = ""
|
|
555
615
|
last_output = ""
|
|
556
616
|
wants_json = bool(step.schema)
|
|
557
617
|
expects_json_array = step.schema in {"string_array", "docs_issue_array"}
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
618
|
+
session_id: str | None = None
|
|
619
|
+
resume_session = False
|
|
620
|
+
selected_profile = step.runner or context.config.llm.runner
|
|
621
|
+
try:
|
|
622
|
+
profile = resolve_runner_profile(context.config, step)
|
|
623
|
+
except Exception as exc: # noqa: BLE001
|
|
624
|
+
raise _named_error(selected_profile, "unknown", stage_name, exc) from exc
|
|
625
|
+
retained_invocation = None
|
|
626
|
+
|
|
627
|
+
# Artifact-only analysis must retain its historical empty scratch cwd;
|
|
628
|
+
# project-aware analysis gets the actual checkout root so the adapter can
|
|
629
|
+
# provide the selected runner's project-read behavior.
|
|
630
|
+
if profile.project_access == "project":
|
|
631
|
+
working_directory = context.repo_root.resolve(strict=True)
|
|
632
|
+
temporary_directory = None
|
|
633
|
+
else:
|
|
634
|
+
temporary_directory = tempfile.TemporaryDirectory(prefix="ai-push-hooks-ask-")
|
|
635
|
+
working_directory = pathlib.Path(temporary_directory.name).resolve(strict=True)
|
|
636
|
+
|
|
637
|
+
try:
|
|
638
|
+
for attempt in range(1, total_attempts + 1):
|
|
639
|
+
invocation = _invoke_runner(
|
|
561
640
|
context,
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
641
|
+
step,
|
|
642
|
+
prompt_text,
|
|
643
|
+
input_paths,
|
|
644
|
+
stage_name,
|
|
645
|
+
working_directory=working_directory,
|
|
646
|
+
session_id=session_id,
|
|
647
|
+
resume_session=resume_session,
|
|
567
648
|
attempt=attempt,
|
|
568
649
|
total_attempts=total_attempts,
|
|
569
|
-
|
|
650
|
+
prior_invocation=retained_invocation,
|
|
570
651
|
)
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
finalize_opencode_session(context, stage_name, session_id)
|
|
577
|
-
details = result.stderr.strip() or result.stdout.strip() or f"exit code {result.return_code}"
|
|
578
|
-
raise HookError(f"OpenCode command failed: {details}")
|
|
579
|
-
try:
|
|
580
|
-
if not wants_json:
|
|
581
|
-
payload = result.output_text
|
|
582
|
-
else:
|
|
583
|
-
if expects_json_array:
|
|
584
|
-
payload = extract_json_array(result.output_text)
|
|
652
|
+
retained_invocation = None
|
|
653
|
+
result = invocation.result
|
|
654
|
+
try:
|
|
655
|
+
if not wants_json:
|
|
656
|
+
payload = result.final_text
|
|
585
657
|
else:
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
+ snippet
|
|
609
|
-
+ "\n```"
|
|
610
|
-
)
|
|
611
|
-
if context.config.llm.json_retry_new_session:
|
|
612
|
-
finalize_opencode_session(context, stage_name, session_id)
|
|
613
|
-
session_id = None
|
|
614
|
-
pr_url = extract_pr_url(last_output)
|
|
615
|
-
if pr_url:
|
|
616
|
-
context.logger.info(
|
|
617
|
-
"llm.invalid_json_pr_url_hint",
|
|
618
|
-
"Detected PR URL in invalid JSON output",
|
|
619
|
-
stage_name=stage_name,
|
|
620
|
-
url=pr_url,
|
|
658
|
+
if expects_json_array:
|
|
659
|
+
payload = extract_json_array(result.final_text)
|
|
660
|
+
else:
|
|
661
|
+
payload = extract_json_object(result.final_text)
|
|
662
|
+
payload = validate_schema(step.schema, payload)
|
|
663
|
+
except HookError as exc:
|
|
664
|
+
last_error = str(exc)
|
|
665
|
+
last_output = result.final_text
|
|
666
|
+
if attempt >= total_attempts:
|
|
667
|
+
_finalize_invocation(context, invocation, failed=True)
|
|
668
|
+
safe_error = _safe_invalid_output(invocation, last_error)
|
|
669
|
+
raise HookError(
|
|
670
|
+
f"Runner profile `{profile.name}` ({profile.type}) failed at stage "
|
|
671
|
+
f"`{stage_name}`: invalid JSON: {safe_error}. "
|
|
672
|
+
f"{_safe_invalid_output(invocation, last_output)}"
|
|
673
|
+
) from exc
|
|
674
|
+
|
|
675
|
+
snippet = last_output[: context.config.llm.invalid_json_feedback_max_chars]
|
|
676
|
+
suffix = (
|
|
677
|
+
"Return ONLY valid JSON array."
|
|
678
|
+
if expects_json_array
|
|
679
|
+
else "Return ONLY valid JSON object."
|
|
621
680
|
)
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
681
|
+
prompt_text = (
|
|
682
|
+
prompt
|
|
683
|
+
+ "\n\nIMPORTANT: Your previous response was invalid JSON and could not be parsed.\n"
|
|
684
|
+
+ f"Parse error: {last_error}\n"
|
|
685
|
+
+ suffix
|
|
686
|
+
+ "\nPrevious invalid output:\n```text\n"
|
|
687
|
+
+ snippet
|
|
688
|
+
+ "\n```"
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
session = result.session
|
|
692
|
+
can_resume = bool(
|
|
693
|
+
getattr(getattr(invocation.runner, "capabilities", None), "supports_resume", False)
|
|
694
|
+
and session is not None
|
|
695
|
+
and session.session_id
|
|
696
|
+
and session.resumable
|
|
697
|
+
)
|
|
698
|
+
if context.config.llm.json_retry_new_session or not can_resume:
|
|
699
|
+
if not session or not session.session_id:
|
|
700
|
+
retry_reason = "session absent"
|
|
701
|
+
elif context.config.llm.json_retry_new_session:
|
|
702
|
+
retry_reason = "fresh session configured"
|
|
703
|
+
else:
|
|
704
|
+
retry_reason = "runner does not support resume"
|
|
705
|
+
retry_message = "Retrying with a fresh runner invocation."
|
|
706
|
+
if retry_reason == "runner does not support resume":
|
|
707
|
+
retry_message = (
|
|
708
|
+
"Retrying with a fresh runner invocation; unsupported session reuse."
|
|
709
|
+
)
|
|
710
|
+
elif retry_reason == "session absent":
|
|
711
|
+
retry_message = (
|
|
712
|
+
"Retrying with a fresh runner invocation; no reusable session was captured."
|
|
713
|
+
)
|
|
714
|
+
context.logger.status(
|
|
715
|
+
"llm.retry_fresh_session",
|
|
716
|
+
retry_message,
|
|
717
|
+
stage_name=stage_name,
|
|
718
|
+
runner_profile=profile.name,
|
|
719
|
+
runner_type=profile.type,
|
|
720
|
+
reason=retry_reason,
|
|
721
|
+
)
|
|
722
|
+
_finalize_invocation(context, invocation, failed=True)
|
|
723
|
+
session_id = None
|
|
724
|
+
resume_session = False
|
|
725
|
+
else:
|
|
726
|
+
# Keep the exact captured session ID. Do not invent a
|
|
727
|
+
# provider-specific resume command for completion output.
|
|
728
|
+
session_id = session.session_id
|
|
729
|
+
resume_session = True
|
|
730
|
+
retained_invocation = invocation
|
|
731
|
+
# The invocation itself completed, although its response
|
|
732
|
+
# failed downstream validation; report truthful metadata
|
|
733
|
+
# before reusing the retained session.
|
|
734
|
+
from .runner_workflow import _completion
|
|
735
|
+
|
|
736
|
+
_completion(context, invocation, failed=True)
|
|
737
|
+
continue
|
|
738
|
+
|
|
739
|
+
_finalize_invocation(context, invocation, failed=False)
|
|
740
|
+
return payload
|
|
741
|
+
raise HookError(
|
|
742
|
+
f"Runner profile `{profile.name}` ({profile.type}) failed at stage `{stage_name}`: "
|
|
743
|
+
"model did not return a valid result"
|
|
744
|
+
) # pragma: no cover
|
|
745
|
+
finally:
|
|
746
|
+
if temporary_directory is not None:
|
|
747
|
+
temporary_directory.cleanup()
|
|
@@ -78,6 +78,13 @@ BEADS_ENV_NAMES = frozenset(
|
|
|
78
78
|
}
|
|
79
79
|
)
|
|
80
80
|
BEADS_ENV_PREFIXES = ("AWS_", "BD_", "BEADS_", "DOLT_")
|
|
81
|
+
BEADS_MIGRATION_OVERRIDE_ENV_NAMES = frozenset(
|
|
82
|
+
{
|
|
83
|
+
"BD_ALLOW_REMOTE_MIGRATE",
|
|
84
|
+
"BD_IGNORE_SCHEMA_SKEW",
|
|
85
|
+
"BD_SMART_GATE",
|
|
86
|
+
}
|
|
87
|
+
)
|
|
81
88
|
GITHUB_REPOSITORY_COMPONENT = re.compile(r"[A-Za-z0-9_.-]+\Z")
|
|
82
89
|
GIT_DIFF_CHUNK_BYTES = 64 * 1024
|
|
83
90
|
GIT_ERROR_BYTES = 64 * 1024
|
|
@@ -950,7 +957,8 @@ def beads_alignment_env() -> dict[str, str]:
|
|
|
950
957
|
return {
|
|
951
958
|
name: value
|
|
952
959
|
for name, value in os.environ.items()
|
|
953
|
-
if name in
|
|
960
|
+
if name not in BEADS_MIGRATION_OVERRIDE_ENV_NAMES
|
|
961
|
+
and (name in BEADS_ENV_NAMES or name.startswith(BEADS_ENV_PREFIXES))
|
|
954
962
|
}
|
|
955
963
|
|
|
956
964
|
|