# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Build and Test Commands

```sh
make install              # uv sync
make test                 # uv run python -m pytest tests/ -v
make lint                 # uv run ruff check swival/ tests/
make format               # uv run ruff format swival/ tests/
make check                # lint + format check
make website              # rebuild docs/pages/ HTML from docs.md/ markdown
```

Run a single test:
```sh
uv run python -m pytest tests/test_config.py::TestLoadConfig::test_missing_files -v
```

Run tests matching a keyword:
```sh
uv run python -m pytest tests/ -k "compact" -v
```

## Architecture

Swival is a coding agent that connects to LM Studio, HuggingFace, OpenRouter, or any OpenAI-compatible server (generic provider) and runs an autonomous tool loop. Python 3.13+, no framework — LiteLLM is the only LLM abstraction.

### Entry Points

- **CLI**: `swival.agent:main()` — parses args, loads config, calls `run_agent_loop()`
- **Library**: `swival.Session` (`.run()` for single-shot, `.ask()` for multi-turn) or `swival.run()` convenience wrapper
- **REPL**: `swival.agent:repl_loop()` — interactive session with prompt-toolkit

### Core Loop (`agent.py:run_agent_loop`)

Each turn: estimate tokens → call LLM → if context overflow, try graduated compaction → extract tool_calls → dispatch each tool → append results → check for guardrail triggers → check for final answer. Compaction levels: `compact_messages` (structured tool-result summaries) → `drop_middle_turns` (importance-scored, AI-summarized) → `aggressive_drop_turns` (keep system + recap + last 2 turns) → `AgentError`.

### Key Modules

- **`agent.py`** — Agent loop, CLI, provider resolution, LLM calling, compaction, system prompt building, `CompactionState` for proactive checkpoints
- **`session.py`** — `Session` class (public API), `Result` dataclass. Lazy setup; `_make_per_run_state()` creates per-conversation state, `_build_loop_kwargs()` wires it to `run_agent_loop()`
- **`tools.py`** — Tool definitions (OpenAI function-calling JSON schema in `TOOLS` list), `dispatch()` routes by name to implementations. All tools return strings; errors start with `"error: "`. MCP tool outputs are size-guarded at dispatch: `_guard_mcp_output()` saves results >20KB to `.swival/cmd_output_*.txt` (capped at 10MB), and error outputs are inline-capped at 20KB
- **`config.py`** — TOML config loading/merging. Precedence: CLI > project `swival.toml` > global `~/.config/swival/config.toml` > defaults. `_UNSET` sentinel distinguishes "not set" from `None`
- **`thinking.py`** — `ThinkingState`: numbered thoughts with revisions and branches, tolerant coercion of malformed payloads
- **`todo.py`** — `TodoState`: persistent `.swival/todo.md` checklist that survives compaction
- **`skills.py`** — Discovers `SKILL.md` files, progressive disclosure (catalog in prompt, full body loaded via `use_skill` tool)
- **`report.py`** — `ReportCollector` for `--report` JSON output; `AgentError`/`ConfigError` exception classes
- **`edit.py`** — Three-pass string replacement: exact → whitespace-trimmed → Unicode-normalized
- **`fetch.py`** — URL fetching with SSRF protections and content conversion
- **`fmt.py`** — All ANSI-formatted stderr output via Rich. `init()` configures color at startup; provides `turn_header`, `tool_call`, `tool_result`, `tool_error`, `think_step`, `todo_update`, `review_feedback`, etc.
- **`reviewer.py`** — `run_as_reviewer()` for `--reviewer-mode`: built-in LLM-as-judge reviewer that speaks the reviewer protocol natively, with `--objective`, `--verify`, and `--review-prompt` support. Reviewer protocol: exit 0 accepts, exit 1 retries with feedback, exit 2 accepts-as-is on reviewer failure
- **`mcp_client.py`** — `McpManager` class: manages connections to MCP (Model Context Protocol) servers via stdio or SSE transports. Runs an asyncio event loop in a background daemon thread; public methods are synchronous via `run_coroutine_threadsafe()`. Handles tool namespacing (`mcp__<server>__<tool>`), schema conversion to OpenAI format, result normalization (returns `tuple[str, bool]` — text and error flag), per-server degradation on failure, and collision detection. `validate_server_name()` for config validation
- **`continue_here.py`** — Continue-here files: saves session state to `.swival/continue.md` on interruption (Ctrl+C, max turns, compaction failure, REPL exit), loads and injects into system prompt on next start. Deterministic-first writing (always writes without LLM), optional LLM enhancement on max-turns path only. `SYNTHETIC_USER_PREFIXES` in `agent.py` identifies system-injected messages to skip when finding the user's real task
- **`tracker.py`** — `FileAccessTracker` for read-before-write guard. Enforced for `write_file`, `edit_file`, `delete_file` unless `--no-read-guard`. `/clear` resets guard state but compaction does not

### How Tools Work

Tools are defined as JSON schema dicts in `tools.py:TOOLS`. `run_command` is conditionally added when commands are whitelisted or yolo is on. `use_skill` is added when skills are discovered. MCP tools are appended at setup time with `mcp__<server>__<tool>` naming; `dispatch()` routes `mcp__` prefixed names to `McpManager.call_tool()` via the routing table (no string splitting). `call_tool()` returns `tuple[str, bool]` (result text, is_error); dispatch destructures this and applies size guards before returning a string. `dispatch()` receives the tool name and parsed args, runs the implementation, returns a string. The agent loop in `agent.py` handles tool_call messages and appends tool results. For large output from `run_command` or MCP tools, results are written to `.swival/cmd_output_*.txt` files readable via `read_file`.

### Config System

Config keys use underscores (`allowed_dirs`), argparse dests may differ (`add_dir`). The mapping is in `config.py:_CONFIG_TO_ARGPARSE`. Boolean inversions: `no_read_guard` → `read_guard`, `no_history` → `history`, `no_continue` → `continue_here`, `quiet` → `verbose`. `config_to_session_kwargs()` translates config dicts to `Session` constructor kwargs. MCP servers are configured via `[mcp_servers.*]` tables in `swival.toml` and/or `.mcp.json` files. Config precedence for MCP: `--no-mcp` > `--mcp-config` > `swival.toml` > `.mcp.json`. Server names must match `[a-zA-Z0-9_-]+` with no double underscores. Each server needs either `command` (stdio) or `url` (SSE), not both.

## Testing Patterns

Tests use pytest with `tmp_path` for filesystem isolation. No `conftest.py` — test helpers like `_sys()`, `_user()`, `_assistant()`, `_assistant_tc()` are defined inline in each test file. Most tests mock `call_llm` via `monkeypatch` to avoid real API calls. Tool tests call internal functions like `_read_file()`, `_write_file()` directly. Compaction tests build synthetic message lists with `_assistant_tc()` for tool-call messages. CI runs Python 3.13 only. The test suite has ~1560 tests and runs in ~25 seconds.

## Conventions

- stdout is exclusively for the final answer; all diagnostics go to stderr
- Tool results are strings, not structured objects — `think` and `todo` return JSON-encoded strings
- Token estimation uses tiktoken's `cl100k_base` encoding
- `call_llm()` skips `tool_choice` when `tools=None` (some providers reject it)
- Compaction summaries use `role: "assistant"` with a non-instructional prefix to prevent prompt injection
- Path validation resolves symlinks before checking `is_relative_to(base_dir)`
- Filesystem access is root-based: `base_dir` plus optional `--add-dir` (read/write) and `--add-dir-ro` (read-only) roots
- Repeating the same tool error triggers automatic guardrail prompts (`IMPORTANT`/`STOP`) — adjust arguments or approach instead of retrying unchanged
