# Plan: Fix todo JSON-string parsing and skill discovery issues

Two bugs observed in `/tmp/report.json` (Qwen3-Coder session):

---

## Bug 1: Todo tool receives JSON-encoded string instead of array

### Problem
The LLM called `todo` with:
```json
{"action": "add", "tasks": "[\"Create a simple XVCL script\", \"Install/locate xvcl tool\", ...]"}
```
`tasks` is a **string** containing a JSON array, not an actual array. The current `_to_stripped_list()` in `todo.py` treats any string as a single item, so all four tasks become one task with text `["Create a simple XVCL script", ...]`. The LLM then repeats this call 5 more times (turns 9, 10, 11, 15, 18) because the todo list never looks right.

### Root cause
Many LLMs (especially smaller/open models) serialize arrays as JSON strings when the schema uses `oneOf: [string, array]`. The tool accepts this silently — it doesn't error, but it doesn't do what the LLM intended.

### Fix
In `todo.py`, update `_to_stripped_list()` to detect when a string looks like a JSON array and parse it:

```python
def _to_stripped_list(raw) -> list[str]:
    """Coerce a string, list, or other value into a list of stripped strings."""
    if isinstance(raw, str):
        stripped = raw.strip()
        # LLMs sometimes JSON-encode the array as a string — unwrap it.
        if stripped.startswith("["):
            try:
                parsed = json.loads(stripped)
                if isinstance(parsed, list):
                    return [t.strip() if isinstance(t, str) else str(t) for t in parsed]
            except (json.JSONDecodeError, ValueError):
                pass
        return [stripped]
    if isinstance(raw, list):
        return [t.strip() if isinstance(t, str) else str(t) for t in raw]
    return [str(raw).strip()]
```

This is:
- **Graceful**: Only triggers when the string starts with `[` and parses as a JSON array.
- **Reliable**: Falls back to treating it as a single string if parsing fails.
- **Transparent**: No special logging needed — the tasks just work as the LLM intended.

### Tests
Add test cases in `tests/test_todo.py`:
1. `tasks` as a JSON-encoded list string → parsed into individual tasks
2. `tasks` as a JSON-encoded list string with `done` action → matches individual items
3. `tasks` as a plain string starting with `[` but not valid JSON → treated as single task
4. `tasks` as a JSON-encoded list containing non-strings → items coerced to strings
5. **Alias compatibility**: `tasks='["hello"]'` (JSON string unwraps to `["hello"]`) with `task='hello'` (string wraps to `["hello"]`) — both normalize to the same value, so no false conflict. This exercises the `_normalize_tasks()` alias conflict path (`todo.py:44`) with the new unwrapping logic.

---

## Bug 2: Skills discovered but not usable by the LLM

### Problem
The report shows `skills_discovered: ["falco", "fastlike", "fastly", "fastly-cli", "viceroy", "xvcl"]` but `skills_used: []`. The timeline shows the LLM spent **6 turns** (turns 1–6) trying to find skill files:

1. `read_file(".SKILLS.md")` → path does not exist
2. `list_files("**/SKILL.md")` → 29 chars (probably "No matches")
3. `read_file("skills/xvcl/SKILL.md")` → path does not exist
4. `list_files("**/SKILL*")` → 29 chars
5. `list_files("**/*.md")` → 29 chars
6. `list_files("**/*xvcl*")` → 9 chars

The LLM never called the `use_skill` tool despite it being available. The skills are in an external `--skills-dir`, not under the project directory, so `list_files` can't find them.

### Root cause analysis
Multiple contributing factors:

1. **`format_skill_catalog()` already tells the LLM about `use_skill`** (line 394 of skills.py), but the instruction is buried in a long paragraph. The LLM doesn't pick up on it — especially weaker models.

2. **The skill catalog in the system prompt shows file paths for local skills but nothing actionable for external skills.** External skills just show `- xvcl: description` with no path and no clear signal that `use_skill` is the way to access them.

3. **The `use_skill` tool description is too terse**: `"Activate a skill to get detailed instructions for a specific task."` — it doesn't mention the available skill names or connect to the catalog list.

### Fixes

#### Fix 2a: Customize `use_skill` tool in `build_tools()` (`agent.py:2252`)
`build_tools()` already deep-copies `RUN_COMMAND_TOOL` for yolo mode — do the same for `USE_SKILL_TOOL`. This is the real runtime seam that constructs the tools list sent to the LLM.

```python
if skills_catalog:
    skill_tool = copy.deepcopy(USE_SKILL_TOOL)
    names = ", ".join(sorted(skills_catalog))
    skill_tool["function"]["description"] = (
        f"Activate a skill to get detailed instructions. "
        f"Available skills: {names}. "
        f"Use this tool instead of trying to find SKILL.md files manually."
    )
    skill_tool["function"]["parameters"]["properties"]["name"]["enum"] = sorted(skills_catalog)
    tools.append(skill_tool)
```

#### Fix 2b: Refine `format_skill_catalog()` instructions — scope by skill locality
The current "How to use skills" text is a wall of text that buries the `use_skill` instruction. Rewrite to be shorter and more directive, while **preserving the local-skill workflow**:

- Local skills already show a concrete `(file: .../SKILL.md)` path in the catalog. The LLM can read those directly — this is the existing contract (`skills.py:382`, tested at `test_skills.py:670`). The `activate_skill()` code also only extends `read_roots` for external skills (`skills.py:347`), confirming direct reads are the expected path for local skills.
- External skills have no path shown. For those, `use_skill` is **required**.

Updated text:

```python
lines.append("### How to use skills")
lines.append(
    "- Call the `use_skill` tool with the skill name to activate it and receive "
    "detailed instructions.\n"
    "- For local skills (those showing a file path above), you may also read the "
    "SKILL.md file directly.\n"
    "- For skills without a path shown, `use_skill` is the only way to access them — "
    "do not search for their files, they are outside the project directory.\n"
    "- If multiple skills apply, activate the minimal set that covers the request."
)
```

This is shorter, front-loads `use_skill`, and avoids the "Do NOT" / "only reliable way" absolutism that would regress local-skill direct reads.

### Tests
Tests must exercise `build_tools()` (the real runtime seam), not hand-build tool lists:

1. `build_tools(resolved_commands={}, skills_catalog=catalog, yolo=False)` → returned `use_skill` tool description includes skill names
2. Same call → `use_skill` name parameter has `enum` with the catalog names
3. `build_tools(resolved_commands={}, skills_catalog={}, yolo=False)` → no `use_skill` tool in list
4. Existing `test_local_skill_shows_file_path` continues to pass (no regression)
5. Updated `format_skill_catalog()` output contains "read the SKILL.md file directly" for local skills guidance

---

## Implementation order

1. Fix `_to_stripped_list()` in `todo.py` (small, isolated change)
2. Add tests for the todo fix (including alias compatibility case)
3. Update `build_tools()` in `agent.py` to customize `use_skill` tool
4. Update `format_skill_catalog()` in `skills.py`
5. Update integration tests in `test_skills.py` to use `build_tools()`
6. Run full test suite
