# Plan: Load CLAUDE.md / AGENT.md into System Prompt

## Problem

The agent has no mechanism to pick up project-specific or user-defined instructions from the working directory. Tools like Claude Code use a `CLAUDE.md` convention to let users inject persistent guidance (coding conventions, architectural context, tool preferences) into every session. swival should support the same pattern so that a `CLAUDE.md` or `AGENT.md` file in `base_dir` automatically enriches the system prompt with project-specific context.

## Goal

When the agent starts, if `CLAUDE.md` or `AGENT.md` exists inside `base_dir`, read its contents and append them to the system prompt. Both files can coexist (both get included). The feature is on by default but can be disabled with `--no-instructions`.

## Design

### File lookup

At startup, after building the base system prompt and before appending the `run_command` section, check for these files in order inside `base_dir`:

1. `CLAUDE.md`
2. `AGENT.md`

Both are optional. If neither exists, behavior is unchanged. If both exist, both are appended (CLAUDE.md first, then AGENT.md), each under its own heading. This lets users maintain a generic `CLAUDE.md` (shared with other tools) alongside an agent-specific `AGENT.md`.

### Prompt structure

Append each file's contents as a clearly delimited section:

```
<project-instructions>
{contents of CLAUDE.md}
</project-instructions>
```

And/or:

```
<agent-instructions>
{contents of AGENT.md}
</agent-instructions>
```

Using XML-style tags (consistent with how many LLM prompts delimit sections) rather than markdown headings, so the model can distinguish project instructions from the base system prompt.

### Trust model and opt-out

Auto-loading repo files into the system prompt is a form of privilege escalation: a malicious `CLAUDE.md` in an untrusted repo can steer model behavior before the user's input. This is an inherent trade-off of the convention (Claude Code has the same property).

Mitigations:

1. **`--no-instructions` flag**: Disables loading of `CLAUDE.md`/`AGENT.md` entirely. Users running the agent against untrusted repos should use this.
2. **Verbose trust warning**: When instructions are loaded and `--verbose` is on, log which files were loaded and their sizes to stderr, so the user has visibility into what's being injected.
3. **`--system-prompt` skips instructions**: When the user provides an explicit `--system-prompt` string, instruction files are *not* appended. The explicit prompt is treated as a complete override. This avoids surprising users who expect `--system-prompt` to give them full control.

### Size cap (memory-safe)

Cap content at 10,000 characters per file. To avoid loading arbitrarily large files into memory, use a bounded `f.read(MAX_INSTRUCTIONS_CHARS + 1)` call. If the read returns more than `MAX_INSTRUCTIONS_CHARS` characters, truncate and append a notice.

## Changes

### 1. `agent.py` — `load_instructions(base_dir, verbose)`

New function near the top (after imports, before `main()`):

```python
MAX_INSTRUCTIONS_CHARS = 10_000

def load_instructions(base_dir: str, verbose: bool) -> str:
    """Load CLAUDE.md and/or AGENT.md from base_dir, if present.

    Returns the combined content as XML-tagged sections, or "" if
    no instruction files are found. Reads at most MAX_INSTRUCTIONS_CHARS+1
    characters per file to avoid unbounded memory use.
    """
    sections = []
    for filename, tag in [("CLAUDE.md", "project-instructions"), ("AGENT.md", "agent-instructions")]:
        path = Path(base_dir).resolve() / filename
        if not path.is_file():
            continue
        try:
            with path.open(encoding="utf-8", errors="replace") as f:
                content = f.read(MAX_INSTRUCTIONS_CHARS + 1)
        except OSError:
            continue
        if len(content) > MAX_INSTRUCTIONS_CHARS:
            content = content[:MAX_INSTRUCTIONS_CHARS] + f"\n[truncated — {filename} exceeds {MAX_INSTRUCTIONS_CHARS} character limit]"
        log(f"Loaded {filename} ({len(content)} chars) from {path.parent}", verbose)
        sections.append(f"<{tag}>\n{content}\n</{tag}>")
    return "\n\n".join(sections)
```

Key decisions:
- `errors="replace"` handles non-UTF8 bytes without crashing (replaces them with U+FFFD). This is consistent — the edge cases section no longer contradicts the code.
- `f.read(MAX_INSTRUCTIONS_CHARS + 1)` reads at most the cap + 1 character, so a 500MB file doesn't get fully loaded.
- `OSError` catch covers permission errors, I/O errors, etc. `UnicodeDecodeError` can't happen with `errors="replace"`.
- Verbose logging shows which files were loaded and their sizes (single code path, no duplicate blocks).

### 2. `agent.py` — CLI flag

Add `--no-instructions` to the argument parser:

```python
parser.add_argument(
    "--no-instructions",
    action="store_true",
    help="Don't load CLAUDE.md or AGENT.md from the base directory.",
)
```

### 3. `agent.py` — `main()`, system prompt assembly

In the block that builds `system_content` (around line 482-496), after loading the default prompt and before the `run_command` appendage:

```python
if not args.no_system_prompt:
    if args.system_prompt:
        system_content = args.system_prompt
    else:
        system_content = DEFAULT_SYSTEM_PROMPT_FILE.read_text(encoding="utf-8")

        # Append project/agent instructions (only with default prompt)
        if not args.no_instructions:
            instructions = load_instructions(base_dir, args.verbose)
            if instructions:
                system_content += "\n\n" + instructions

    if resolved_commands:
        # ... existing run_command block ...
```

Instructions are loaded only when using the default system prompt. `--system-prompt "custom"` skips instruction files (the custom prompt is a complete replacement for the default prompt + instructions). The `run_command` section is still appended in both cases because it describes an active tool, not static instructions — it's operational config, not overridable guidance. `--no-system-prompt` skips the entire system message, so nothing is appended at all. `--no-instructions` explicitly disables instruction file loading regardless of which prompt mode is used.

### 4. `system_prompt.txt` — no changes

The base system prompt doesn't need to mention CLAUDE.md. The injected content speaks for itself.

### 5. Tests — `tests/test_instructions.py`

New test file with a fixture that creates a temp directory as `base_dir`.

**File discovery:**
- `test_no_instructions_files` — Neither file exists. `load_instructions()` returns `""`.
- `test_claude_md_only` — Create `CLAUDE.md` with known content. Result contains `<project-instructions>` tags wrapping the content.
- `test_agent_md_only` — Create `AGENT.md` with known content. Result contains `<agent-instructions>` tags wrapping the content.
- `test_both_files` — Both exist. Result contains both sections, `project-instructions` first.

**Content handling:**
- `test_truncation` — `CLAUDE.md` with 15,000 chars. Result is capped at 10,000 chars plus truncation notice.
- `test_unreadable_file_skipped` — Monkeypatch `Path.open` to raise `OSError` for the instructions path. `load_instructions()` returns `""` without raising.
- `test_empty_file_included` — Empty `CLAUDE.md` still produces the tags (the model sees an empty section, which is fine and unambiguous).
- `test_non_utf8_replaced` — File with invalid UTF-8 bytes. Content loads with replacement characters (U+FFFD), no error raised.

**Integration (mock-based):**
- `test_instructions_in_system_message` — Mock `call_llm` to capture messages. Run `main()` with a `CLAUDE.md` in `base_dir`. Verify the system message contains the CLAUDE.md content.
- `test_no_system_prompt_flag_skips_instructions` — With `--no-system-prompt`, no system message at all, so instructions are not loaded.
- `test_custom_system_prompt_skips_instructions` — With `--system-prompt "custom"`, instructions from CLAUDE.md are *not* appended (full override semantics).
- `test_no_instructions_flag` — With `--no-instructions`, CLAUDE.md exists but is not loaded.
- `test_custom_system_prompt_with_commands_appends_run_command` — With `--system-prompt "custom"` and `--allowed-commands "ls"`, instructions from CLAUDE.md are skipped but the `run_command` section is still appended to the system message.

### 6. Update existing tests — `tests/test_logging.py`

The four `SimpleNamespace` blocks in `test_logging.py` (lines 56, 113, 167, 225) that mock `parse_args` must include `no_instructions=True` to avoid `AttributeError` at runtime. All four use `no_system_prompt=True` so the value doesn't affect their behavior — they just need the attribute to exist.

Add to each `SimpleNamespace`:
```python
no_instructions=True,
```

Any other test files that build a mock args namespace for `agent.main()` need the same treatment. A grep for `SimpleNamespace` across `tests/` will catch them all.

### 7. No changes needed

- `tools.py` — No tool changes (the model doesn't interact with these files via tools).
- `thinking.py` — Unrelated.
- `edit.py` — Unrelated.
- `system_prompt.txt` — Content unchanged.

## Edge cases

- **Symlinked CLAUDE.md**: `Path.is_file()` follows symlinks, so a symlinked instructions file works. This is a fixed filename in a known directory, not a user-supplied path.
- **Binary/non-UTF8 file**: Handled by `errors="replace"` — invalid bytes become U+FFFD. No separate exception handling needed.
- **`--system-prompt` override**: Instruction files are *not* appended (the custom prompt replaces default prompt + instructions). The `run_command` section *is* still appended because it describes an active tool capability, not static guidance. This distinction is documented and tested.
- **`--no-instructions`**: Skips instruction file loading entirely. Intended for running against untrusted repos.
- **Very large file**: `f.read(MAX_INSTRUCTIONS_CHARS + 1)` bounds memory usage. A multi-GB file only reads ~10KB.

## Sequence of implementation

1. Add `--no-instructions` flag to argparse in `agent.py`
2. Add `load_instructions()` to `agent.py`
3. Wire it into `main()` system prompt assembly
4. Write tests in `tests/test_instructions.py`
5. Run tests, fix issues
6. Update CLAUDE.md to document the feature
