# Plan: Auto-split string commands in non-yolo mode

## Problem

Small models frequently pass `"command": "ls -la src/"` (a plain string) instead of the required `"command": ["ls", "-la", "src/"]` (JSON array). In non-yolo mode this returns a hard error and wastes a turn. The model may repeat the same mistake several times before figuring out the correct syntax, or never figure it out at all.

## Current behavior

1. If `command` is a string, try `json.loads()` to see if it's a JSON-encoded array (e.g. `"[\"ls\", \"-la\"]"`). If so, use it.
2. Otherwise, if yolo mode: pass the raw string to `_run_shell_command()` (shell execution).
3. Otherwise: return an error telling the model to use an array.

## Proposed behavior

Insert a new step between 1 and 3: if the string contains no shell metacharacters, split on whitespace and execute the resulting array. Append a short corrective note to the output so the model learns the right format.

### Shell metacharacter detection

A string is "safe to split" if it contains **none** of these characters:

```
| & ; > < $ ` \ " ' * ? ~ # ! { } ( ) [ ] \n
```

This covers pipes, redirections, variable expansion, globbing, subshells, quoting, escapes, and newlines. The check is a simple `set` intersection — no regex needed.

```python
_SHELL_CHARS = set('|&;><$`\\"\'*?~#!{}()[]\n')

def _is_safe_to_split(s: str) -> bool:
    return not (_SHELL_CHARS & set(s))
```

#### Limitation: arguments with spaces

`str.split()` cannot handle arguments that contain intentional spaces (e.g. a filename `"my file.txt"`). Those would need quoting, which we reject. This is intentionally conservative — the auto-split is a convenience for simple commands like `ls -la` or `grep -n pattern file.py`, not a general shell parser. Commands with space-bearing arguments must use the array format. The corrective note makes this clear.

#### Platform note

Backslash is in the reject set, which means Windows-style paths like `dir C:\Users` will not be auto-split. This is acceptable — swival targets Unix and the non-yolo `run_command` uses `subprocess` without a shell. Windows paths in arguments should use the array format. No special-casing needed.

### Splitting

Use `str.split()` (splits on any whitespace including tabs, drops empties). No `shlex.split()` — that interprets quotes and escapes, which we've already rejected.

### Corrective message

The existing `_finalize` helper appends a note when `was_repaired` is true. Currently it says `"(auto-corrected: command was passed as a string, converted to array)"`. This wording is fine for the JSON-array-in-a-string repair path too, so **don't change `_finalize`**. Both repair paths (JSON parse and whitespace split) do the same thing conceptually: convert a string to an array.

The message is terse enough that it doesn't clutter output, and accurate for both cases.

### Changes to `_run_command()` in `tools.py`

The only change is inside the `if isinstance(command, str):` block. Currently the non-yolo path returns an error. Replace it with a safe-split attempt:

```python
if repaired_command is None:
    if unrestricted:
        return _run_shell_command(command, base_dir, timeout)
    # Try whitespace-split for shell-safe strings
    if _is_safe_to_split(command):
        repaired_command = command.split()
    else:
        return (
            'error: "command" must be a JSON array of strings, not a single string.\n'
            'Wrong: "command": "grep -n pattern file.py"\n'
            'Right: "command": ["grep", "-n", "pattern", "file.py"]\n'
            "Each argument must be a separate element in the array.\n"
            "Shell syntax (&&, |, >, 2>&1, etc.) is not supported — "
            "run one command at a time."
        )

if repaired_command is not None:
    command = repaired_command
    was_repaired = True
```

Note: the shell-chars error is returned directly (not through `_finalize`) because `was_repaired` is still false at that point — `_finalize` would be a no-op anyway.

Empty strings after split produce an empty list, which is caught by the existing `if not command:` check downstream.

### What about strings that are valid JSON arrays?

The existing `json.loads()` repair runs first and handles that case. No change needed.

## Tests (`tests/test_yolo.py` or new test)

1. **Simple string is auto-split and executes**: pass `"echo hello"` as a string in non-yolo mode with `echo` whitelisted. Assert output contains `"hello"` and the "(auto-corrected: ...)" note.
2. **String with shell chars is rejected**: pass `"echo hello | cat"` in non-yolo mode. Assert the existing error message is returned (no auto-corrected note).
3. **Empty string after split**: pass `"  "` (whitespace only). Assert `"error: command list is empty"`.
4. **JSON-encoded array still works**: pass `'["ls", "-la"]'` as a string. Assert it executes (existing behavior, regression check).
5. **Yolo mode unchanged**: pass a string with shell chars in yolo mode. Assert it goes through `_run_shell_command()` (existing behavior).
6. **Tab-separated string**: pass `"ls\t-la"` (tab between tokens). Assert it splits correctly and executes.
7. **Backslash rejected**: pass `"echo C:\\Users"`. Assert it returns the error (not auto-split), confirming the reject set works.

## Scope

- One new helper in `tools.py` (`_is_safe_to_split`)
- A few lines inserted into the `isinstance(command, str)` branch of `_run_command()`
- `_finalize` is unchanged
- Tests
- No CLI, schema, or architectural changes
