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.
@@ -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_",
@@ -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) -> None:
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
- 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)
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 context.config.llm.variant:
502
- 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])
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 stdout.strip():
534
- 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)
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 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(
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
- for attempt in range(1, total_attempts + 1):
559
- try:
560
- 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(
561
640
  context,
562
- stage_name=stage_name,
563
- purpose=f"{step.type}:{step.id}",
564
- prompt=prompt_text,
565
- files=input_paths,
566
- 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,
567
648
  attempt=attempt,
568
649
  total_attempts=total_attempts,
569
- existing_session_id=session_id,
650
+ prior_invocation=retained_invocation,
570
651
  )
571
- except Exception: # noqa: BLE001
572
- finalize_opencode_session(context, stage_name, session_id)
573
- raise
574
- session_id = result.session_id
575
- if result.return_code != 0:
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
- payload = extract_json_object(result.output_text)
587
- payload = validate_schema(step.schema, payload)
588
- except HookError as exc:
589
- last_error = str(exc)
590
- last_output = result.output_text
591
- if attempt >= total_attempts:
592
- finalize_opencode_session(context, stage_name, session_id)
593
- raise HookError(
594
- f"Model failed to return valid JSON for {stage_name}: "
595
- f"{last_error}. {last_output[:400]}"
596
- ) from exc
597
- snippet = last_output[: context.config.llm.invalid_json_feedback_max_chars]
598
- if expects_json_array:
599
- suffix = "Return ONLY valid JSON array."
600
- else:
601
- suffix = "Return ONLY valid JSON object."
602
- prompt_text = (
603
- prompt
604
- + "\n\nIMPORTANT: Your previous response was invalid JSON and could not be parsed.\n"
605
- + f"Parse error: {last_error}\n"
606
- + suffix
607
- + "\nPrevious invalid output:\n```text\n"
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
- continue
623
- finalize_opencode_session(context, stage_name, session_id)
624
- return payload
625
- 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()
@@ -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 BEADS_ENV_NAMES or name.startswith(BEADS_ENV_PREFIXES)
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