ai-push-hooks 0.2.1 → 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 +45 -1
- package/README.md +563 -84
- 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 -74
- 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_",
|
|
@@ -270,11 +275,11 @@ def export_opencode_session_json(
|
|
|
270
275
|
return True
|
|
271
276
|
|
|
272
277
|
|
|
273
|
-
def delete_opencode_session(context: RuntimeContext, session_id: str) ->
|
|
278
|
+
def delete_opencode_session(context: RuntimeContext, session_id: str) -> bool:
|
|
274
279
|
with tempfile.TemporaryDirectory(
|
|
275
280
|
prefix="ai-push-hooks-session-delete-"
|
|
276
281
|
) as temporary_directory:
|
|
277
|
-
run_command(
|
|
282
|
+
completed = run_command(
|
|
278
283
|
[
|
|
279
284
|
context.opencode_executable or resolve_opencode_executable(),
|
|
280
285
|
"session",
|
|
@@ -288,6 +293,7 @@ def delete_opencode_session(context: RuntimeContext, session_id: str) -> None:
|
|
|
288
293
|
env=opencode_isolation_env(context, non_agent_opencode_config(), "session-delete"),
|
|
289
294
|
inherit_env=False,
|
|
290
295
|
)
|
|
296
|
+
return completed.returncode == 0
|
|
291
297
|
|
|
292
298
|
|
|
293
299
|
def finalize_opencode_session(context: RuntimeContext, stage_name: str, session_id: str | None) -> None:
|
|
@@ -334,6 +340,7 @@ def build_opencode_security_config(
|
|
|
334
340
|
allow_paths: tuple[str, ...] = (),
|
|
335
341
|
*,
|
|
336
342
|
non_vcs_working_directory: pathlib.Path | None = None,
|
|
343
|
+
project_read_root: pathlib.Path | None = None,
|
|
337
344
|
) -> tuple[str, dict[str, Any]]:
|
|
338
345
|
permissions: dict[str, Any] = {
|
|
339
346
|
"*": "deny",
|
|
@@ -351,6 +358,7 @@ def build_opencode_security_config(
|
|
|
351
358
|
"skill": "deny",
|
|
352
359
|
"todowrite": "deny",
|
|
353
360
|
"question": "deny",
|
|
361
|
+
"doom_loop": "deny",
|
|
354
362
|
}
|
|
355
363
|
if agent_policy == "read-only":
|
|
356
364
|
agent_name = OPENCODE_READ_ONLY_AGENT
|
|
@@ -395,6 +403,31 @@ def build_opencode_security_config(
|
|
|
395
403
|
else:
|
|
396
404
|
raise HookError(f"Unsupported OpenCode agent policy: {agent_policy}")
|
|
397
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
|
+
|
|
398
431
|
return agent_name, {
|
|
399
432
|
"$schema": "https://opencode.ai/config.json",
|
|
400
433
|
"plugin": [],
|
|
@@ -446,6 +479,9 @@ def call_opencode(
|
|
|
446
479
|
attempt: int | None = None,
|
|
447
480
|
total_attempts: int | None = None,
|
|
448
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",
|
|
449
485
|
) -> OpenCodeRunResult:
|
|
450
486
|
"""Run OpenCode with a policy-specific isolated working directory.
|
|
451
487
|
|
|
@@ -461,6 +497,10 @@ def call_opencode(
|
|
|
461
497
|
raise HookError("OpenCode apply agent requires an isolated staging directory")
|
|
462
498
|
if agent == "read-only" and allow_paths:
|
|
463
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")
|
|
464
504
|
|
|
465
505
|
validated_files = validate_opencode_attachments(context, files)
|
|
466
506
|
if working_directory is None:
|
|
@@ -482,9 +522,18 @@ def call_opencode(
|
|
|
482
522
|
non_vcs_working_directory=(
|
|
483
523
|
resolved_working_directory if agent == "apply" else None
|
|
484
524
|
),
|
|
525
|
+
project_read_root=(
|
|
526
|
+
resolved_working_directory if project_access == "project" else None
|
|
527
|
+
),
|
|
485
528
|
)
|
|
486
529
|
executable = context.opencode_executable or resolve_opencode_executable()
|
|
487
|
-
|
|
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)
|
|
488
537
|
isolated_env = opencode_isolation_env(context, security_config, stage_name)
|
|
489
538
|
cmd = [
|
|
490
539
|
executable,
|
|
@@ -494,11 +543,11 @@ def call_opencode(
|
|
|
494
543
|
"--pure",
|
|
495
544
|
"--format",
|
|
496
545
|
"json",
|
|
497
|
-
"--model",
|
|
498
|
-
context.config.llm.model,
|
|
499
546
|
]
|
|
500
|
-
if
|
|
501
|
-
cmd.extend(["--
|
|
547
|
+
if effective_model:
|
|
548
|
+
cmd.extend(["--model", effective_model])
|
|
549
|
+
if effective_variant:
|
|
550
|
+
cmd.extend(["--variant", effective_variant])
|
|
502
551
|
if existing_session_id:
|
|
503
552
|
cmd.extend(["--session", existing_session_id])
|
|
504
553
|
else:
|
|
@@ -529,8 +578,11 @@ def call_opencode(
|
|
|
529
578
|
session_id, text_output = parse_opencode_json_run_output(completed.stdout or "")
|
|
530
579
|
stdout = completed.stdout or ""
|
|
531
580
|
stderr = completed.stderr or ""
|
|
532
|
-
if context.config.logging.print_llm_output and
|
|
533
|
-
|
|
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)
|
|
534
586
|
return OpenCodeRunResult(
|
|
535
587
|
output_text=text_output if text_output else stdout.strip(),
|
|
536
588
|
session_id=session_id or existing_session_id,
|
|
@@ -540,85 +592,156 @@ def call_opencode(
|
|
|
540
592
|
)
|
|
541
593
|
|
|
542
594
|
|
|
543
|
-
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(
|
|
544
603
|
context: RuntimeContext,
|
|
545
604
|
step: StepConfig,
|
|
546
605
|
prompt: str,
|
|
547
606
|
input_paths: list[pathlib.Path],
|
|
548
607
|
stage_name: str,
|
|
549
608
|
) -> Any:
|
|
609
|
+
from .runner_workflow import _finalize_invocation, _invoke_runner, _named_error
|
|
610
|
+
from ..config import resolve_runner_profile
|
|
611
|
+
|
|
550
612
|
total_attempts = context.config.llm.json_max_retries + 1
|
|
551
|
-
session_id: str | None = None
|
|
552
613
|
prompt_text = prompt
|
|
553
614
|
last_error = ""
|
|
554
615
|
last_output = ""
|
|
555
616
|
wants_json = bool(step.schema)
|
|
556
617
|
expects_json_array = step.schema in {"string_array", "docs_issue_array"}
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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(
|
|
560
640
|
context,
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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,
|
|
566
648
|
attempt=attempt,
|
|
567
649
|
total_attempts=total_attempts,
|
|
568
|
-
|
|
650
|
+
prior_invocation=retained_invocation,
|
|
569
651
|
)
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
finalize_opencode_session(context, stage_name, session_id)
|
|
576
|
-
details = result.stderr.strip() or result.stdout.strip() or f"exit code {result.return_code}"
|
|
577
|
-
raise HookError(f"OpenCode command failed: {details}")
|
|
578
|
-
try:
|
|
579
|
-
if not wants_json:
|
|
580
|
-
payload = result.output_text
|
|
581
|
-
else:
|
|
582
|
-
if expects_json_array:
|
|
583
|
-
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
|
|
584
657
|
else:
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
+ snippet
|
|
608
|
-
+ "\n```"
|
|
609
|
-
)
|
|
610
|
-
if context.config.llm.json_retry_new_session:
|
|
611
|
-
finalize_opencode_session(context, stage_name, session_id)
|
|
612
|
-
session_id = None
|
|
613
|
-
pr_url = extract_pr_url(last_output)
|
|
614
|
-
if pr_url:
|
|
615
|
-
context.logger.info(
|
|
616
|
-
"llm.invalid_json_pr_url_hint",
|
|
617
|
-
"Detected PR URL in invalid JSON output",
|
|
618
|
-
stage_name=stage_name,
|
|
619
|
-
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."
|
|
620
680
|
)
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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()
|