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.
@@ -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, extract_pr_url, resolve_storage_path, run_command
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) -> None:
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
- context.logger.llm_call(stage_name, purpose, context.config.llm.model, attempt, total_attempts)
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 context.config.llm.variant:
501
- cmd.extend(["--variant", context.config.llm.variant])
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 stdout.strip():
533
- print(stdout)
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 run_llm_step(
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
- for attempt in range(1, total_attempts + 1):
558
- try:
559
- result = call_opencode(
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
- stage_name=stage_name,
562
- purpose=f"{step.type}:{step.id}",
563
- prompt=prompt_text,
564
- files=input_paths,
565
- agent="read-only",
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
- existing_session_id=session_id,
650
+ prior_invocation=retained_invocation,
569
651
  )
570
- except Exception: # noqa: BLE001
571
- finalize_opencode_session(context, stage_name, session_id)
572
- raise
573
- session_id = result.session_id
574
- if result.return_code != 0:
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
- payload = extract_json_object(result.output_text)
586
- payload = validate_schema(step.schema, payload)
587
- except HookError as exc:
588
- last_error = str(exc)
589
- last_output = result.output_text
590
- if attempt >= total_attempts:
591
- finalize_opencode_session(context, stage_name, session_id)
592
- raise HookError(
593
- f"Model failed to return valid JSON for {stage_name}: "
594
- f"{last_error}. {last_output[:400]}"
595
- ) from exc
596
- snippet = last_output[: context.config.llm.invalid_json_feedback_max_chars]
597
- if expects_json_array:
598
- suffix = "Return ONLY valid JSON array."
599
- else:
600
- suffix = "Return ONLY valid JSON object."
601
- prompt_text = (
602
- prompt
603
- + "\n\nIMPORTANT: Your previous response was invalid JSON and could not be parsed.\n"
604
- + f"Parse error: {last_error}\n"
605
- + suffix
606
- + "\nPrevious invalid output:\n```text\n"
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
- continue
622
- finalize_opencode_session(context, stage_name, session_id)
623
- return payload
624
- raise HookError(f"Model failed to return valid JSON for {stage_name}") # pragma: no cover
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()