vet

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README | LICENSE

commit 813556985bceb94e553224a2425ca68599219c08
parent 2af4896a77b673867fb1de2ec8b87ab162597d1b
Author: Andrew D. Laack <andrew@laack.co>
Date:   Sat, 21 Feb 2026 23:51:01 +0000

Improved logging / user messaging (#125)

* Improved logging / user messaging

* Warn on logging to file issues

* Explicit precendence

* Updated documentation

* improve consistency with other cli options

* Updated messaging
Diffstat:
MDEVELOPMENT.md | 7++++---
Mskills/vet/SKILL.md | 4++--
Mvet/api.py | 4++--
Mvet/cli/config/cli_config_schema.py | 6++++--
Mvet/cli/config/cli_config_test.py | 12++++++------
Mvet/cli/main.py | 90++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mvet/imbue_core/agents/primitives/resource_limits.py | 2++
7 files changed, 79 insertions(+), 46 deletions(-)

diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md @@ -165,14 +165,15 @@ Vet is published to PyPI via the `publish-pypi.yml` GitHub Actions workflow. Dep ### Logging -When creating a new entry point into vet, you must call `configure_logging()` from `vet.cli.main`. +When creating a new entry point into vet, you must call `configure_logging(verbose: int, log_file: Path | None)` from `vet.cli.main`. + +User-facing status messages (top-level lifecycle, warnings visible to the user) use `print(..., file=sys.stderr)` directly — not loguru. Loguru is for internal diagnostics only. Log level heuristics: - **TRACE** - API payloads, token counts, dollar costs, agent subprocess messages. - **DEBUG** - Everything internal: API exceptions before re-raise, retries, fallbacks, identifier selection, history loading, context assembly. All LLM provider exception handlers must log at DEBUG before raising (see `_openai_exception_manager` for the pattern). -- **INFO** - Top-level run lifecycle only. Do not add new INFO messages without team discussion. -- **WARNING** - Degraded conditions: LLM content blocked/flagged, unrecognized config values, malformed user data, spend limit warnings. +- **WARNING** - Degraded conditions: LLM content blocked/flagged, unrecognized config values, malformed user data. Note: spend limit warnings also `print()` directly to stderr so they are always visible to the user. - **ERROR** - Failures that prevent producing results. Use `log_exception()` from `vet.imbue_core.async_monkey_patches` for tracebacks. ### README links diff --git a/skills/vet/SKILL.md b/skills/vet/SKILL.md @@ -70,9 +70,9 @@ Vet analyzes the full git diff from the base commit. This may include changes fr ## Common Options - `--base-commit REF`: Git ref for diff base (default: HEAD) -- `--model MODEL`: LLM model to use (default: claude-4-6-opus) +- `--model MODEL`: LLM model to use (default: claude-opus-4-6) - `--confidence-threshold N`: Minimum confidence 0.0-1.0 (default: 0.8) - `--output-format FORMAT`: Output as `text`, `json`, or `github` -- `--quiet`: Suppress progress output +- `--quiet`: Suppress status messages and 'No issues found.' - `--agentic`: Mode that routes analysis through the locally installed Claude Code or Codex CLI instead of calling the API directly. Try this if vet fails due to missing API keys. Slower (~3 min) so not recommended as the default. - `--help`: Show comprehensive list of options diff --git a/vet/api.py b/vet/api.py @@ -104,7 +104,7 @@ def find_issues( conversation_history: tuple[ConversationMessageUnion, ...] | None = None, extra_context: str | None = None, ) -> tuple[IdentifiedVerifyIssue, ...]: - logger.info( + logger.debug( "Finding issues in {repo_path} relative to {relative_to}", repo_path=repo_path, relative_to=relative_to, @@ -112,7 +112,7 @@ def find_issues( base_commit, diff, diff_no_binary = get_code_to_check(relative_to, repo_path) if not diff.strip(): - logger.info( + logger.debug( "No code changes detected in repo {repo_path} since the specified relative_to commit {relative_to}, skipping issue identification", repo_path=repo_path, relative_to=relative_to, diff --git a/vet/cli/config/cli_config_schema.py b/vet/cli/config/cli_config_schema.py @@ -38,8 +38,9 @@ class CliConfigPreset(BaseModel): output: str | None = None output_format: str | None = None output_fields: list[str] | None = None - verbose: bool | None = None + verbose: int | None = Field(default=None, ge=0, le=2) quiet: bool | None = None + log_file: str | None = None class CliDefaults(BaseModel): @@ -62,8 +63,9 @@ class CliDefaults(BaseModel): output: str | None = None output_format: str = "text" output_fields: list[str] | None = None - verbose: bool = False + verbose: int = 0 quiet: bool = False + log_file: str | None = None CLI_DEFAULTS = CliDefaults() diff --git a/vet/cli/config/cli_config_test.py b/vet/cli/config/cli_config_test.py @@ -93,7 +93,7 @@ def test_parse_cli_config_from_dict_handles_all_fields() -> None: assert preset.output == "results.json" assert preset.output_format == "json" assert preset.output_fields == ["file", "line", "message"] - assert preset.verbose is True + assert preset.verbose == 1 assert preset.quiet is False @@ -113,7 +113,7 @@ def test_merge_presets_preserves_base_when_override_is_none() -> None: confidence_threshold=0.8, max_workers=4, model="base-model", - verbose=True, + verbose=1, ) override = CliConfigPreset() @@ -122,7 +122,7 @@ def test_merge_presets_preserves_base_when_override_is_none() -> None: assert result.confidence_threshold == 0.8 assert result.max_workers == 4 assert result.model == "base-model" - assert result.verbose is True + assert result.verbose == 1 def test_cli_defaults_and_cli_config_preset_have_same_fields() -> None: @@ -381,7 +381,7 @@ def test_apply_config_preset_applies_all_values() -> None: max_workers=4, output_format="json", output_fields=["file", "line"], - verbose=True, + verbose=1, quiet=False, ) @@ -393,7 +393,7 @@ def test_apply_config_preset_applies_all_values() -> None: assert result.max_workers == 4 assert result.output_format == "json" assert result.output_fields == ["file", "line"] - assert result.verbose is True + assert result.verbose == 1 def test_apply_config_preset_cli_args_take_precedence() -> None: @@ -405,7 +405,7 @@ def test_apply_config_preset_cli_args_take_precedence() -> None: max_spend=None, output_format="text", output_fields=None, - verbose=False, + verbose=0, quiet=False, enabled_issue_codes=None, disabled_issue_codes=None, diff --git a/vet/cli/main.py b/vet/cli/main.py @@ -4,6 +4,7 @@ from __future__ import annotations # Given this, we want to have the most standardized outputs possible. import argparse import json +import os import subprocess import sys from importlib.metadata import version @@ -31,7 +32,7 @@ from vet.imbue_core.data_types import get_valid_issue_code_values VERSION = version("verify-everything") _ISSUE_CODE_FIELDS = frozenset({"enabled_issue_codes", "disabled_issue_codes"}) -_PATH_FIELDS = frozenset({"repo", "output"}) +_PATH_FIELDS = frozenset({"repo", "output", "log_file"}) _PATH_LIST_FIELDS = frozenset({"extra_context"}) @@ -145,7 +146,7 @@ def create_parser() -> argparse.ArgumentParser: default=CLI_DEFAULTS.model, metavar="MODEL", # Hardcoded to avoid importing cli.models at module level (~1s of SDK imports). - help="LLM to use for analysis (default: claude-opus-4-6). ", + help="LLM to use for analysis (default: claude-opus-4-6).", ) model_group.add_argument( "--list-models", @@ -218,16 +219,23 @@ def create_parser() -> argparse.ArgumentParser: output_group.add_argument( "--verbose", "-v", - action="store_true", + action="count", default=CLI_DEFAULTS.verbose, - help="Show verbose logger messages", + help="Increase verbosity. Use -v for debug output, -vv for full trace (raw LLM responses, API details).", ) output_group.add_argument( "--quiet", "-q", action="store_true", default=CLI_DEFAULTS.quiet, - help="Suppress progress indicator and non-essential output", + help="Suppress status messages and 'No issues found.'", + ) + output_group.add_argument( + "--log-file", + type=Path, + default=None, + metavar="FILE", + help="Write full trace log to FILE (default: ~/.local/state/vet/vet.log). Also accepts VET_LOG_FILE environment variable.", ) parser.add_argument( @@ -302,15 +310,26 @@ def list_configs(cli_configs: dict[str, CliConfigPreset], repo_path: Path) -> No print() -def configure_logging(verbose: bool, quiet: bool) -> None: +_DEFAULT_LOG_FILE = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) / "vet" / "vet.log" + + +def configure_logging(verbose: int, log_file: Path | None) -> None: + if log_file is None: + log_file = Path(os.environ["VET_LOG_FILE"]) if "VET_LOG_FILE" in os.environ else _DEFAULT_LOG_FILE logger.remove() - if quiet: - level = "WARNING" - elif verbose: - level = "DEBUG" - else: - level = "INFO" - logger.add(sys.stderr, level=level) + if verbose == 1: + logger.add(sys.stderr, level="DEBUG", format="{level}: {message}") + elif verbose >= 2: + logger.add(sys.stderr, level="TRACE", format="{level} | {name}:{line} | {message}") + + try: + log_file.parent.mkdir(parents=True, exist_ok=True) + logger.add(log_file, level="TRACE", rotation="10 MB", retention=3) + except OSError as e: + print( + f"vet: warning: could not write to log file {log_file}: {e.strerror}", + file=sys.stderr, + ) def load_conversation_from_command(command: str, cwd: Path) -> tuple: @@ -319,7 +338,10 @@ def load_conversation_from_command(command: str, cwd: Path) -> tuple: logger.debug("Running history loader command: {}", command) result = subprocess.run(command, shell=True, capture_output=True, text=True, cwd=cwd) if result.returncode != 0: - logger.warning(f"History loader command failed with exit code {result.returncode}: {result.stderr}") + print( + f"vet: warning: history loader command failed (exit {result.returncode}): {result.stderr.strip()}", + file=sys.stderr, + ) return () if not result.stdout.strip(): logger.debug("History loader command returned empty output, no conversation history loaded") @@ -380,13 +402,13 @@ def main(argv: list[str] | None = None) -> int: try: user_config = load_models_config(repo_path) except ConfigLoadError as e: - print(f"Error loading model configuration: {e}", file=sys.stderr) + print(f"vet: could not load model configuration: {e}", file=sys.stderr) return 2 try: custom_guides_config = load_custom_guides_config(repo_path) except ConfigLoadError as e: - print(f"Error loading custom guides: {e}", file=sys.stderr) + print(f"vet: could not load custom guides: {e}", file=sys.stderr) return 2 if args.list_issue_codes: @@ -404,7 +426,7 @@ def main(argv: list[str] | None = None) -> int: try: cli_configs = load_cli_config(repo_path) except ConfigLoadError as e: - print(f"Error loading CLI configuration: {e}", file=sys.stderr) + print(f"vet: could not load CLI configuration: {e}", file=sys.stderr) return 2 if args.list_configs: @@ -416,55 +438,55 @@ def main(argv: list[str] | None = None) -> int: preset = get_config_preset(args.config, cli_configs, repo_path) args = apply_config_preset(args, preset) except ConfigLoadError as e: - print(f"Error: {e}", file=sys.stderr) + print(f"vet: {e}", file=sys.stderr) return 2 if not repo_path.exists(): - print(f"Error: Repository path does not exist: {repo_path}", file=sys.stderr) + print(f"vet: repository path does not exist: {repo_path}", file=sys.stderr) return 2 if not repo_path.is_dir(): - print(f"Error: Repository path is not a directory: {repo_path}", file=sys.stderr) + print(f"vet: repository path is not a directory: {repo_path}", file=sys.stderr) return 2 if args.extra_context: for extra_context_file in args.extra_context: if not extra_context_file.exists(): print( - f"Error: Extra context file does not exist: {extra_context_file}", + f"vet: extra context file does not exist: {extra_context_file}", file=sys.stderr, ) return 2 if args.verbose and args.quiet: print( - "Error: --verbose and --quiet are mutually exclusive", + "vet: --verbose and --quiet are mutually exclusive", file=sys.stderr, ) return 2 if not 0.0 <= args.confidence_threshold <= 1.0: print( - f"Error: Confidence threshold must be between 0.0 and 1.0, got: {args.confidence_threshold}", + f"vet: confidence threshold must be between 0.0 and 1.0, got: {args.confidence_threshold}", file=sys.stderr, ) return 2 if not 0.0 <= args.temperature <= 2.0: print( - f"Error: Temperature must be between 0.0 and 2.0, got: {args.temperature}", + f"vet: temperature must be between 0.0 and 2.0, got: {args.temperature}", file=sys.stderr, ) return 2 if args.max_spend is not None and args.max_spend <= 0: print( - f"Error: Max spend must be a positive number, got: {args.max_spend}", + f"vet: max spend must be a positive number, got: {args.max_spend}", file=sys.stderr, ) return 2 - configure_logging(args.verbose, args.quiet) + configure_logging(args.verbose, args.log_file) from vet.api import find_issues from vet.cli.config.loader import build_language_model_config @@ -494,7 +516,7 @@ def main(argv: list[str] | None = None) -> int: try: validate_output_fields(args.output_fields) except ValueError as e: - print(f"Error: {e}", file=sys.stderr) + print(f"vet: {e}", file=sys.stderr) return 2 model_id = args.model or DEFAULT_MODEL_ID @@ -502,13 +524,13 @@ def main(argv: list[str] | None = None) -> int: try: model_id = validate_model_id(model_id, user_config) except ValueError as e: - print(f"Error: {e}", file=sys.stderr) + print(f"vet: {e}", file=sys.stderr) return 2 try: validate_api_key_for_model(model_id, user_config) except Exception as e: - print(f"Error: {e}", file=sys.stderr) + print(f"vet: {e}", file=sys.stderr) return 2 # TODO: Support OFFLINE, UPDATE_SNAPSHOT, and MOCKED modes. @@ -536,6 +558,12 @@ def main(argv: list[str] | None = None) -> int: enable_deduplication=not args.agentic, ) + if not args.quiet: + print( + f"analyzing {repo_path} (relative to {args.base_commit})", + file=sys.stderr, + ) + try: issues = find_issues( repo_path=repo_path, @@ -549,13 +577,13 @@ def main(argv: list[str] | None = None) -> int: except (PromptTooLongError, BadAPIRequestError) as e: if _is_context_overflow(e): print( - "Error: The review failed because too much context was provided to the model. " + "vet: review failed because too much context was provided to the model. " "Consider using a model with a larger context window.", file=sys.stderr, ) return 2 if isinstance(e, BadAPIRequestError): - print(f"Error: {e.error_message}", file=sys.stderr) + print(f"vet: {e.error_message}", file=sys.stderr) return 1 raise diff --git a/vet/imbue_core/agents/primitives/resource_limits.py b/vet/imbue_core/agents/primitives/resource_limits.py @@ -1,6 +1,7 @@ import asyncio import datetime import os +import sys from asyncio import CancelledError from asyncio import Task from asyncio import TaskGroup @@ -296,6 +297,7 @@ class ResourceLimits: # TODO: make a more configurable warning system, right now just logs async def _warn(self, message: str) -> None: + print(f"vet: warning: {message}", file=sys.stderr) logger.warning(message) async def _clear_old_authorizations(self, _is_already_locked: bool = False) -> None: