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:
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: