# Plan: `delete_file` tool (soft-delete)

## Motivation

The agent can create and edit files but can't remove them. This matters when refactoring (rename = create new + delete old), cleaning up generated artifacts, or following user instructions to remove something.

Permanent deletion is too dangerous for an autonomous agent. Instead, `delete_file` soft-deletes by moving files to `.swival/trash/` with metadata for manual recovery.

## Tool schema

```python
{
    "type": "function",
    "function": {
        "name": "delete_file",
        "description": (
            "Delete a file by moving it to the trash. "
            "Cannot delete directories."
        ),
        "parameters": {
            "type": "object",
            "properties": {
                "file_path": {
                    "type": "string",
                    "description": "Path to the file to delete.",
                },
            },
            "required": ["file_path"],
        },
    },
}
```

No `recursive` flag — deleting entire directory trees is too destructive for an agent tool. Directories are rejected with an error telling the model to remove individual files instead.

Goes in the base `TOOLS` list (always available, like read/write/edit).

## Trash mechanics

### Directory layout

```
.swival/
  trash/
    index.jsonl          # append-only metadata log
    a1b2c3d4/            # uuid4 hex, no dashes
      config.yaml        # original file, name preserved
```

Each trashed file lives in its own UUID directory. Prevents collisions while keeping the original filename visible for manual recovery via `ls`.

Short hex form (`uuid4().hex`) keeps return messages compact.

### index.jsonl

One JSON line appended per delete:

```json
{"trash_id": "a1b2c3d4", "original_path": "src/config.yaml", "timestamp": "2026-02-25T14:30:00Z", "tool_call_id": "call_abc123"}
```

Fields:
- `trash_id`: UUID hex
- `original_path`: the path as the agent supplied it (the raw `file_path` argument). For files inside `base_dir` this is typically a relative path like `src/config.yaml`. For files in `--allow-dir` or yolo paths it may be absolute (`/mnt/data/dump.csv`). We store the agent's input verbatim rather than computing `os.path.relpath` — this avoids `../../` paths that are confusing to read, and faithfully records what the agent asked to delete. The resolved absolute path is not stored separately; it can be reconstructed from context if needed.
- `timestamp`: ISO 8601 UTC
- `tool_call_id`: from `tool_call.id`, forwarded through `dispatch()` kwargs

The index is informational. The trash directory structure is self-contained — if `index.jsonl` is missing, files are still recoverable by inspection.

### Passing tool_call_id to dispatch

`handle_tool_call` has `tool_call.id` but doesn't forward it. Add `tool_call_id=tool_call.id` to the `dispatch()` kwargs. `dispatch()` already accepts `**kwargs`, so no signature change needed — `_delete_file` picks it out.

## Implementation: `_delete_file()`

Goes in `tools.py` after `_edit_file`.

```python
def _delete_file(
    file_path: str,
    base_dir: str,
    extra_write_roots: list[Path] = (),
    unrestricted: bool = False,
    tracker=None,
    tool_call_id: str = "",
) -> str:
```

Steps:
1. `safe_resolve(file_path, base_dir, ...)` — same sandbox check as write/edit.
2. Build the pre-resolution path for existence/type checks so symlinks are handled correctly.
3. Check existence: return `"error: file not found: {file_path}"` if `not original.exists() and not original.is_symlink()`. The `is_symlink()` check catches dangling symlinks — `exists()` follows the link and returns False when the target is gone, but the link entry itself is still on disk and should be deletable.
4. Check type: if `original.is_symlink()`, it's a link entry regardless of what it points to (or whether it points anywhere) — skip to step 5. Otherwise, if `original.is_dir()`, return `"error: is a directory (delete individual files instead)"`. This means symlinks-to-directories and dangling symlinks can be trashed, while actual directories cannot.
5. If tracker: for symlinks, skip the read guard entirely — the agent is removing a link entry, not modifying the file's content, and directory listings (which is how symlinks-to-directories get "read") don't register in the tracker anyway. For regular files, call `tracker.check_write_allowed(str(resolved), exists=True)` as usual.
6. Run `_cleanup_trash(base_dir)` to enforce retention limits.
7. Generate `trash_id = uuid4().hex`.
8. Create `.swival/trash/{trash_id}/`, move the original file into it (preserving filename).
9. Run `_cleanup_trash(base_dir)` again to enforce the size cap including the file just trashed. This handles the case where a single large file pushes trash over `TRASH_MAX_BYTES` — the oldest entries (not the one just added) get evicted immediately rather than lingering until a future delete.
10. Append a line to `index.jsonl` (after both moves and cleanup succeed).
11. Return `f"Trashed {file_path} -> .swival/trash/{trash_id}"`.

**Index append failure:** If the move succeeds (step 8) but the `index.jsonl` append fails (step 10), the tool still returns the success message with the trash ID. The file is safely in the trash and recoverable by inspection — the index is a convenience, not a requirement. The `OSError` is swallowed with a warning to stderr via `fmt.warning()`.

Move uses `Path.rename()` (atomic on same FS). Cross-mount fallback with `shutil.move` for `--allow-dir` paths on different filesystems.

**Symlink handling:** move the **original path** (link entry), not the resolved target. `safe_resolve()` validates the resolved target is in-bounds first. This means a symlink inside the repo that points outside the sandbox **cannot** be trashed — `safe_resolve` rejects it before we reach the move. This is intentional: the sandbox boundary is strict and applies uniformly to all mutating tools. A symlink pointing outside is indistinguishable from a path-traversal attempt from the sandbox's perspective. If the user needs to remove such a link, they can do it manually or use `run_command` with `rm`.

## Concurrency

Swival is single-threaded and single-process per `base_dir` in normal use, so concurrent access to `.swival/trash/` is not a practical concern today. Still, the design is defensively tolerant of races:

- **index.jsonl appends**: use `os.open(path, O_WRONLY | O_APPEND | O_CREAT)` + a single `os.write()` of one encoded JSONL line. A single `write()` to a regular file with `O_APPEND` is effectively atomic for short lines on any modern POSIX system. Python's `open("a")` text mode doesn't guarantee one kernel `write()` per logical line, so we use the low-level API.
- **Trash moves**: each delete writes into its own UUID directory, so two concurrent deletes never conflict on the move step.
- **Cleanup races**: two `_cleanup_trash` calls could both see the same old entry and both try to `shutil.rmtree` it. The second call gets a `FileNotFoundError` — catch and ignore it. Similarly, `stat()` / size collection during cleanup can race with a concurrent delete of the same entry — catch `FileNotFoundError` there too and skip the vanished entry. No data loss, since the entry was going to be deleted anyway.

No file locking is needed. If a future multi-agent mode is added (e.g. parallel tool execution), this analysis still holds.

## Dispatch routing

```python
elif name == "delete_file":
    return _delete_file(
        args["file_path"],
        base_dir,
        extra_write_roots=kwargs.get("extra_write_roots", []),
        unrestricted=kwargs.get("yolo", False),
        tracker=kwargs.get("file_tracker"),
        tool_call_id=kwargs.get("tool_call_id", ""),
    )
```

## Retention limits

Two caps, enforced via `_cleanup_trash` which runs twice per `_delete_file` call (before and after the move):

| Limit | Default | Rationale |
|---|---|---|
| Age | 7 days | Old trash is unlikely to be needed |
| Total size | 50 MB | Prevents runaway disk usage |

```python
TRASH_MAX_AGE = 7 * 24 * 3600  # seconds
TRASH_MAX_BYTES = 50 * 1024 * 1024
```

`_cleanup_trash(base_dir, exclude=None)`:
1. Walk `.swival/trash/*/`, collect `(mtime, size, path)` for each UUID entry. Skip `index.jsonl`. All entries — including `exclude` — contribute to the total size budget.
2. `shutil.rmtree` entries older than `TRASH_MAX_AGE` (but never the `exclude` entry).
3. If total size still exceeds `TRASH_MAX_BYTES`, remove oldest-first until under cap, skipping the `exclude` entry. The excluded entry's size counts against the budget but it cannot be evicted.

In short: `exclude` controls eviction eligibility, not size accounting.

Called twice in `_delete_file`: once before the move (general housekeeping, no exclude), once after with `exclude=trash_id` (enforce cap including the new entry, evicting older items to make room). This keeps trash bounded in the common case. The only scenario where trash exceeds `TRASH_MAX_BYTES` is when the newly trashed file alone is larger than the cap — since it's excluded from eviction and everything else has already been purged. Acceptable: the file was already on disk before the delete, and the next cleanup pass will evict it.

## System prompt

Add to the Filesystem section in `system_prompt.txt`:

```
- `delete_file`: Delete a file (moved to `.swival/trash/` for recovery). Cannot delete directories.
```

## Read-before-write interaction

Deletion is a write-class operation. The existing `FileAccessTracker.check_write_allowed()` blocks if the file exists and hasn't been read or previously written by the agent. Exception: symlinks (including dangling ones) skip the read guard entirely, since removing a link entry isn't a content mutation and symlink targets often can't be "read" in the tracker's sense anyway.

One small change to `tracker.py`: the error message says `"overwriting or editing"` — change to `"modifying"` which covers write, edit, and delete.

After deletion the path stays in `written_files`. If the agent recreates the file at the same path, the write is allowed. Correct behavior.

## Think nudge

`agent.py:1437` has a "think before mutating" nudge that fires for `edit_file`/`write_file`. Add `"delete_file"` to the tuple.

## Tests

New file: `tests/test_delete.py`.

### delete_file tests

| Test | What it checks |
|---|---|
| `test_delete_moves_to_trash` | File gone from original path, present in `.swival/trash/{id}/{name}`, correct content |
| `test_delete_returns_trash_id` | Return message matches `"Trashed foo.txt -> .swival/trash/{hex}"` |
| `test_delete_index_entry` | `index.jsonl` has a line with correct fields |
| `test_delete_two_same_name` | Deleting `a/f.txt` then `b/f.txt` creates two distinct trash entries |
| `test_delete_nonexistent` | Returns `"error: file not found"` |
| `test_delete_directory_rejected` | Returns error, directory untouched |
| `test_delete_outside_sandbox` | `safe_resolve` rejects `../` escapes |
| `test_delete_blocked_without_read` | Tracker blocks deletion of unread file |
| `test_delete_allowed_after_read` | Tracker allows deletion after `read_file` |
| `test_delete_own_creation` | File created via `write_file` can be deleted without reading |
| `test_delete_no_tracker` | Works when `tracker=None` (`--no-read-guard`) |
| `test_delete_extra_write_roots` | File in `--allow-dir` path can be deleted |
| `test_delete_yolo` | Unrestricted mode skips sandbox checks |
| `test_delete_symlink_moves_link` | Symlink-to-file entry is trashed, target file untouched |
| `test_delete_symlink_to_dir` | Symlink-to-directory can be trashed (link entry removed, target dir untouched) |
| `test_delete_symlink_to_dir_skips_guard` | Symlink-to-dir deletion succeeds with tracker enabled even without a prior read |
| `test_delete_dangling_symlink` | Dangling symlink (target deleted) can be trashed, link entry removed |
| `test_delete_symlink_outside_sandbox` | Symlink inside repo pointing outside sandbox is rejected by `safe_resolve` |
| `test_delete_allow_dir_absolute_path` | File in `--allow-dir` records absolute `original_path` in index |
| `test_delete_index_append_failure` | Move succeeds but index write fails → tool returns success, file is in trash |
| `test_delete_dispatch` | `dispatch("delete_file", ...)` routes correctly |

### Retention tests

| Test | What it checks |
|---|---|
| `test_cleanup_removes_old_entries` | Entries older than `TRASH_MAX_AGE` are purged |
| `test_cleanup_respects_size_cap` | Oldest entries removed when total exceeds `TRASH_MAX_BYTES`; excluded entry's size counts in budget |
| `test_cleanup_excluded_counts_in_budget` | 30MB excluded + 30MB old → old evicted (total 60MB > cap), even though non-excluded alone is under cap |
| `test_cleanup_preserves_just_trashed` | Post-move cleanup with `exclude` doesn't evict the new entry |
| `test_cleanup_large_single_file` | Trashing a >50MB file evicts all older entries but preserves itself (trash temporarily over cap) |
| `test_cleanup_race_entry_already_gone` | `_cleanup_trash` handles `FileNotFoundError` from a concurrently removed entry without raising |

### Agent integration

| Test | What it checks |
|---|---|
| `test_think_nudge_includes_delete` | Think nudge fires for `delete_file` (in guardrails tests) |
| `test_tool_call_id_forwarded` | `tool_call.id` reaches `_delete_file` via dispatch kwargs |

## Files changed

| File | Change |
|---|---|
| `swival/tools.py` | Add schema to `TOOLS`, implement `_delete_file()`, `_cleanup_trash()`, add dispatch case |
| `swival/tracker.py` | Error message: `"overwriting or editing"` → `"modifying"` |
| `swival/agent.py` | Add `"delete_file"` to think-nudge tuple; pass `tool_call_id=tool_call.id` in `dispatch()` call |
| `swival/system_prompt.txt` | One-line addition |
| `tests/test_delete.py` | New test file |
| `CLAUDE.md` | Mention `delete_file` and `.swival/trash/` in architecture docs |
