# vault-agent Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build a background daemon that maintains an Obsidian vault as a living, transferable knowledge base — enriching frontmatter, adding semantic links, classifying notes into PARA folders, cleaning junk, and generating project manuals.

**Architecture:** Python daemon at `~/.vault-agent/` with three triggers (FSEvents watchdog, login batch, idle processor). Each note is embedded with Qwen3-Embed, stored in ChromaDB, then passed to Llama 4 Scout with top-20 similar notes as context. Scout returns structured JSON with inferred tags, links, and PARA zone. A separate scheduler handles vault cleanup reports and project manual generation.

**Tech Stack:** Python 3.11+, uv, watchdog (FSEvents), chromadb, python-frontmatter, httpx, pydantic, pytest

---

## File Map

```
~/.vault-agent/
├── vault_agent/
│   ├── config.py           # VaultConfig dataclass, exclusion lists, constants
│   ├── ollama_client.py    # httpx wrapper: embed() + generate_json()
│   ├── indexer.py          # ChromaDB: init, upsert, search, delete
│   ├── frontmatter.py      # parse(), safe_merge(), write() with iCloud delay
│   ├── context_builder.py  # Builds Scout prompt from note + similar notes
│   ├── processor.py        # 8-step per-note pipeline
│   ├── watcher.py          # FSEvents watchdog with 3s debounce
│   ├── startup.py          # Login batch + idle queue + state.json management
│   ├── cleanup.py          # Junk detection → writes 02 Areas/Vault Cleanup.md
│   ├── manuals.py          # Codebase reader → USER_MANUAL.md + DEVELOPER_MANUAL.md
│   └── agent.py            # Entry point: wires all components together
├── tests/
│   ├── conftest.py         # tmp vault fixture, mock Ollama factory
│   ├── test_frontmatter.py
│   ├── test_context_builder.py
│   ├── test_processor.py
│   ├── test_classifier.py  # PARA + tag inference via mocked Scout
│   ├── test_cleanup.py
│   └── test_manuals.py
├── pyproject.toml
└── plist/
    └── com.vadym.vault-agent.plist
```

---

## Phase 1 — Core Daemon (Tasks 1–6)

### Task 1: Project scaffold

**Files:**
- Create: `~/.vault-agent/pyproject.toml`
- Create: `~/.vault-agent/vault_agent/__init__.py`
- Create: `~/.vault-agent/vault_agent/config.py`
- Create: `~/.vault-agent/tests/conftest.py`

- [ ] **Step 1: Create project directory**

```bash
mkdir -p ~/.vault-agent/vault_agent ~/.vault-agent/tests ~/.vault-agent/plist
cd ~/.vault-agent
```

- [ ] **Step 2: Write pyproject.toml**

```toml
[project]
name = "vault-agent"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
    "watchdog>=4.0",
    "chromadb>=0.5",
    "python-frontmatter>=1.1",
    "httpx>=0.27",
    "pydantic>=2.7",
]

[project.scripts]
vault-agent = "vault_agent.agent:main"

[tool.pytest.ini_options]
testpaths = ["tests"]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
```

- [ ] **Step 3: Init uv and install deps**

```bash
cd ~/.vault-agent
uv sync
```

Expected: `Resolved N packages` with no errors.

- [ ] **Step 4: Write config.py**

```python
from dataclasses import dataclass, field
from pathlib import Path

VAULT_PATH = Path.home() / "Library/Mobile Documents/iCloud~md~obsidian/Documents/VadymSamoilenko"
AGENT_DIR  = Path.home() / ".vault-agent"
DB_DIR     = AGENT_DIR / "db"
STATE_FILE = AGENT_DIR / "state.json"
LOG_FILE   = AGENT_DIR / "vault-agent.log"

OLLAMA_BASE   = "http://localhost:11434"
LLM_MODEL     = "llama4:scout"
EMBED_MODEL   = "qwen3-embed:0.6b"

LINK_CONFIDENCE   = 0.80   # minimum for inserting a [[link]]
MOVE_CONFIDENCE   = 0.85   # minimum for auto folder move
EMBED_SIMILARITY  = 0.65   # minimum for including in context
TOP_K_SIMILAR     = 20     # notes passed to Scout as context
ICLOUD_WRITE_DELAY = 2.0   # seconds between file writes
WATCHER_DEBOUNCE  = 3.0    # seconds
IDLE_CPU_THRESHOLD = 20.0  # percent
IDLE_SUSTAINED_S  = 30     # seconds before idle processing starts
IDLE_NOTE_INTERVAL = 2.0   # seconds between notes in idle mode

EXCLUDED_DIRS: frozenset[str] = frozenset([
    "wiki", "99 Daily", "raw", "Templates", "output",
    "Hoarder", ".obsidian", ".claude", ".agents",
    ".smart-env", ".qwen", "posts",
])

EXCLUDED_FILES: frozenset[str] = frozenset([
    "02 Areas/Pending Commands.md",
    "02 Areas/Work Tasks.md",
    "CLAUDE.md",
    "Next-Session-Prompt.md",
])

# In 01 Projects/, agent may only write these filenames
ALLOWED_IN_PROJECTS: frozenset[str] = frozenset([
    "USER_MANUAL.md",
    "DEVELOPER_MANUAL.md",
])

TAG_TAXONOMY = {
    "client": ["3m","barclays","ferrero","ford","hm","loreal","lusa",
               "pimco","solventum","dowjones","baic","oliver"],
    "status": ["active","production","paused","archived"],
    "tech":   ["fastapi","react","docker","nextjs","node","python","gemini",
               "claude","elevenlabs","redis","mongodb","postgresql","celery",
               "typescript","rag","sqlite","azure-ad","flask","llamaindex"],
    "type":   ["project","dashboard","sop","reference","daily","review","meeting"],
    "domain": ["ai","devops","accessibility","analytics","homelab","security"],
}


def is_excluded(path: Path, vault_path: Path = VAULT_PATH) -> bool:
    """Return True if path must not be processed by the agent."""
    rel = path.relative_to(vault_path)
    parts = rel.parts

    # Check excluded dirs (first component)
    if parts[0] in EXCLUDED_DIRS:
        return True

    # Check excluded files
    rel_str = str(rel)
    if rel_str in EXCLUDED_FILES:
        return True

    # 01 Projects/ — only allow specific filenames
    if parts[0] == "01 Projects" and len(parts) >= 2:
        return path.name not in ALLOWED_IN_PROJECTS

    return False
```

- [ ] **Step 5: Write conftest.py**

```python
import shutil
import pytest
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock


@pytest.fixture
def tmp_vault(tmp_path: Path) -> Path:
    """Minimal vault structure for tests."""
    (tmp_vault := tmp_path / "vault").mkdir()
    (tmp_vault / "02 Areas").mkdir()
    (tmp_vault / "03 Resources").mkdir()
    (tmp_vault / "05 Aimpress LTD").mkdir()
    return tmp_vault


@pytest.fixture
def mock_ollama():
    """Factory that returns a mock OllamaClient (synchronous — matches OllamaClient)."""
    client = MagicMock()
    client.embed.return_value = [0.1] * 768
    client.generate_json.return_value = {
        "frontmatter": {"status": "active", "tags": ["type/reference"], "type": "area"},
        "links": ["[[03 Resources/SSH Servers]] — infrastructure reference"],
        "para_zone": "03 Resources",
        "confidence": 0.91,
    }
    return client
```

- [ ] **Step 6: Run tests (empty suite should pass)**

```bash
cd ~/.vault-agent && uv run pytest -v
```

Expected: `no tests ran` (0 collected, 0 errors).

- [ ] **Step 7: Commit**

```bash
cd ~/.vault-agent && git init && git add -A
git commit -m "chore: scaffold vault-agent project"
```

---

### Task 2: Ollama client

**Files:**
- Create: `~/.vault-agent/vault_agent/ollama_client.py`
- Test: `~/.vault-agent/tests/test_ollama_client.py`

- [ ] **Step 1: Write failing tests**

```python
# tests/test_ollama_client.py
import pytest
import respx
import httpx
from vault_agent.ollama_client import OllamaClient


@pytest.fixture
def client():
    return OllamaClient(base_url="http://localhost:11434")


@respx.mock
def test_embed_returns_vector(client):
    respx.post("http://localhost:11434/api/embeddings").mock(
        return_value=httpx.Response(200, json={"embedding": [0.1, 0.2, 0.3]})
    )
    result = client.embed("hello world")
    assert result == [0.1, 0.2, 0.3]


@respx.mock
def test_generate_json_returns_dict(client):
    payload = {"frontmatter": {"status": "active"}, "links": [], "para_zone": "03 Resources", "confidence": 0.9}
    respx.post("http://localhost:11434/api/generate").mock(
        return_value=httpx.Response(200, json={"response": '{"frontmatter":{"status":"active"},"links":[],"para_zone":"03 Resources","confidence":0.9}'})
    )
    result = client.generate_json("prompt text")
    assert result["para_zone"] == "03 Resources"


@respx.mock
def test_embed_raises_on_server_error(client):
    respx.post("http://localhost:11434/api/embeddings").mock(
        return_value=httpx.Response(500)
    )
    with pytest.raises(RuntimeError, match="Ollama embed failed"):
        client.embed("hello")
```

- [ ] **Step 2: Add respx to deps and run — verify FAIL**

```bash
cd ~/.vault-agent && uv add respx --dev
uv run pytest tests/test_ollama_client.py -v
```

Expected: `ImportError: cannot import name 'OllamaClient'`

- [ ] **Step 3: Implement ollama_client.py**

```python
import json
import httpx
from vault_agent.config import OLLAMA_BASE, LLM_MODEL, EMBED_MODEL


class OllamaClient:
    def __init__(self, base_url: str = OLLAMA_BASE, timeout: float = 120.0):
        self._http = httpx.Client(base_url=base_url, timeout=timeout)

    def embed(self, text: str, model: str = EMBED_MODEL) -> list[float]:
        resp = self._http.post("/api/embeddings", json={"model": model, "prompt": text})
        if resp.status_code != 200:
            raise RuntimeError(f"Ollama embed failed: {resp.status_code} {resp.text}")
        return resp.json()["embedding"]

    def generate_json(self, prompt: str, model: str = LLM_MODEL) -> dict:
        resp = self._http.post("/api/generate", json={
            "model": model,
            "prompt": prompt,
            "format": "json",
            "stream": False,
        })
        if resp.status_code != 200:
            raise RuntimeError(f"Ollama generate failed: {resp.status_code} {resp.text}")
        return json.loads(resp.json()["response"])

    def close(self):
        self._http.close()

    def __enter__(self):
        return self

    def __exit__(self, *_):
        self.close()
```

- [ ] **Step 4: Run tests — verify PASS**

```bash
uv run pytest tests/test_ollama_client.py -v
```

Expected: 3 passed.

- [ ] **Step 5: Commit**

```bash
git add vault_agent/ollama_client.py tests/test_ollama_client.py
git commit -m "feat: add OllamaClient with embed and generate_json"
```

---

### Task 3: Frontmatter module

**Files:**
- Create: `~/.vault-agent/vault_agent/frontmatter.py`
- Test: `~/.vault-agent/tests/test_frontmatter.py`

- [ ] **Step 1: Write failing tests**

```python
# tests/test_frontmatter.py
import time
from pathlib import Path
import pytest
from vault_agent.frontmatter import parse_note, safe_merge, write_note, normalize_tag_syntax


def test_parse_note_with_frontmatter(tmp_path):
    f = tmp_path / "note.md"
    f.write_text("---\nstatus: active\ntags:\n  - type/project\n---\n\n# Hello\n\nBody text.")
    meta, body = parse_note(f)
    assert meta["status"] == "active"
    assert body.strip() == "# Hello\n\nBody text."


def test_parse_note_without_frontmatter(tmp_path):
    f = tmp_path / "note.md"
    f.write_text("# Hello\n\nBody text.")
    meta, body = parse_note(f)
    assert meta == {}
    assert "Hello" in body


def test_safe_merge_adds_missing_fields():
    existing = {"status": "active", "created": "2026-01-01"}
    additions = {"status": "paused", "tags": ["type/reference"], "created": "2026-05-01"}
    result = safe_merge(existing, additions)
    assert result["status"] == "active"      # existing not overwritten
    assert result["created"] == "2026-01-01" # existing not overwritten
    assert result["tags"] == ["type/reference"]  # new field added


def test_safe_merge_always_updates_modified():
    existing = {"modified": "2025-01-01"}
    result = safe_merge(existing, {})
    from datetime import date
    assert result["modified"] == str(date.today())


def test_normalize_tag_syntax_converts_inline_array():
    meta = {"tags": "[oliver, dashboard, cc]"}
    result = normalize_tag_syntax(meta)
    assert result["tags"] == ["oliver", "dashboard", "cc"]


def test_write_note_roundtrip(tmp_path):
    f = tmp_path / "note.md"
    f.write_text("---\nstatus: active\n---\n\n# Hello")
    write_note(f, {"status": "active", "modified": "2026-05-18"}, "# Hello\n", delay=0)
    content = f.read_text()
    assert "modified: 2026-05-18" in content
    assert "# Hello" in content
```

- [ ] **Step 2: Run — verify FAIL**

```bash
uv run pytest tests/test_frontmatter.py -v
```

Expected: `ImportError`

- [ ] **Step 3: Implement frontmatter.py**

```python
import re
import time
from datetime import date
from pathlib import Path
from typing import Any
import frontmatter as fm
from vault_agent.config import ICLOUD_WRITE_DELAY


def parse_note(path: Path) -> tuple[dict[str, Any], str]:
    """Return (metadata_dict, body_text). metadata is {} if no frontmatter."""
    post = fm.load(str(path))
    return dict(post.metadata), post.content


def normalize_tag_syntax(meta: dict[str, Any]) -> dict[str, Any]:
    """Convert inline tag arrays like '[a, b, c]' to Python lists."""
    tags = meta.get("tags")
    if isinstance(tags, str) and tags.startswith("["):
        cleaned = tags.strip("[]")
        meta["tags"] = [t.strip() for t in cleaned.split(",") if t.strip()]
    return meta


def safe_merge(existing: dict[str, Any], additions: dict[str, Any]) -> dict[str, Any]:
    """
    Merge additions into existing frontmatter.
    Rules:
    - Never overwrite existing non-None values (except `modified`)
    - `modified` is always set to today
    - `tags` are merged (union, no duplicates)
    """
    result = dict(existing)
    result["modified"] = str(date.today())

    for key, value in additions.items():
        if key == "modified":
            continue
        if key == "tags":
            existing_tags = set(result.get("tags") or [])
            new_tags = set(value or [])
            result["tags"] = sorted(existing_tags | new_tags)
        elif key not in result or result[key] is None:
            result[key] = value

    return result


def write_note(path: Path, meta: dict[str, Any], body: str, delay: float = ICLOUD_WRITE_DELAY) -> None:
    """Write note back to disk with iCloud-safe delay."""
    post = fm.Post(body, **meta)
    content = fm.dumps(post)
    path.write_text(content, encoding="utf-8")
    if delay > 0:
        time.sleep(delay)
```

- [ ] **Step 4: Run tests — verify PASS**

```bash
uv run pytest tests/test_frontmatter.py -v
```

Expected: 6 passed.

- [ ] **Step 5: Commit**

```bash
git add vault_agent/frontmatter.py tests/test_frontmatter.py
git commit -m "feat: add frontmatter parse/merge/write with safety rules"
```

---

### Task 4: ChromaDB indexer

**Files:**
- Create: `~/.vault-agent/vault_agent/indexer.py`
- Test: `~/.vault-agent/tests/test_indexer.py`

- [ ] **Step 1: Write failing tests**

```python
# tests/test_indexer.py
import pytest
from pathlib import Path
from vault_agent.indexer import VaultIndex


@pytest.fixture
def index(tmp_path):
    return VaultIndex(db_path=tmp_path / "db")


def test_upsert_and_search(index):
    index.upsert("note-1", [0.1] * 768, {"path": "02 Areas/Goals.md"})
    index.upsert("note-2", [0.2] * 768, {"path": "03 Resources/Guide.md"})
    results = index.search([0.1] * 768, top_k=2)
    assert len(results) == 2
    assert results[0]["id"] == "note-1"


def test_search_returns_top_k(index):
    for i in range(10):
        vec = [float(i) / 10] * 768
        index.upsert(f"note-{i}", vec, {"path": f"note-{i}.md"})
    results = index.search([0.5] * 768, top_k=3)
    assert len(results) == 3


def test_delete_removes_entry(index):
    index.upsert("note-x", [0.5] * 768, {"path": "x.md"})
    index.delete("note-x")
    results = index.search([0.5] * 768, top_k=5)
    ids = [r["id"] for r in results]
    assert "note-x" not in ids


def test_get_existing_ids(index):
    index.upsert("a", [0.1] * 768, {"path": "a.md"})
    index.upsert("b", [0.2] * 768, {"path": "b.md"})
    ids = index.existing_ids()
    assert "a" in ids and "b" in ids
```

- [ ] **Step 2: Run — verify FAIL**

```bash
uv run pytest tests/test_indexer.py -v
```

Expected: `ImportError`

- [ ] **Step 3: Implement indexer.py**

```python
import hashlib
from pathlib import Path
import chromadb
from vault_agent.config import DB_DIR


def _note_id(path: Path) -> str:
    """Stable ID from path."""
    return hashlib.md5(str(path).encode()).hexdigest()


class VaultIndex:
    def __init__(self, db_path: Path = DB_DIR):
        db_path.mkdir(parents=True, exist_ok=True)
        self._client = chromadb.PersistentClient(path=str(db_path))
        self._col = self._client.get_or_create_collection(
            name="vault",
            metadata={"hnsw:space": "cosine"},
        )

    def upsert(self, note_id: str, embedding: list[float], metadata: dict) -> None:
        self._col.upsert(ids=[note_id], embeddings=[embedding], metadatas=[metadata])

    def search(self, embedding: list[float], top_k: int = 20) -> list[dict]:
        if self._col.count() == 0:
            return []
        results = self._col.query(query_embeddings=[embedding], n_results=min(top_k, self._col.count()))
        out = []
        for i, nid in enumerate(results["ids"][0]):
            out.append({"id": nid, "distance": results["distances"][0][i], **results["metadatas"][0][i]})
        return out

    def delete(self, note_id: str) -> None:
        self._col.delete(ids=[note_id])

    def existing_ids(self) -> set[str]:
        return set(self._col.get()["ids"])
```

- [ ] **Step 4: Run tests — verify PASS**

```bash
uv run pytest tests/test_indexer.py -v
```

Expected: 4 passed.

- [ ] **Step 5: Commit**

```bash
git add vault_agent/indexer.py tests/test_indexer.py
git commit -m "feat: add ChromaDB VaultIndex with upsert/search/delete"
```

---

### Task 5: Context builder + Scout prompt

**Files:**
- Create: `~/.vault-agent/vault_agent/context_builder.py`
- Test: `~/.vault-agent/tests/test_context_builder.py`

- [ ] **Step 1: Write failing tests**

```python
# tests/test_context_builder.py
from vault_agent.context_builder import build_prompt
from vault_agent.config import TAG_TAXONOMY


def test_prompt_contains_note_content():
    prompt = build_prompt(
        note_content="# My Note\n\nAbout FastAPI deployments.",
        similar_notes=[],
        existing_meta={},
    )
    assert "FastAPI deployments" in prompt


def test_prompt_contains_taxonomy():
    prompt = build_prompt("content", [], {})
    assert "client/" in prompt
    assert "tech/" in prompt
    assert "domain/" in prompt


def test_prompt_includes_similar_notes():
    similar = [{"path": "03 Resources/FastAPI Guide.md", "snippet": "FastAPI setup..."}]
    prompt = build_prompt("content", similar, {})
    assert "FastAPI Guide" in prompt


def test_prompt_instructs_json_output():
    prompt = build_prompt("content", [], {})
    assert "frontmatter" in prompt
    assert "links" in prompt
    assert "para_zone" in prompt
    assert "confidence" in prompt
```

- [ ] **Step 2: Run — verify FAIL**

```bash
uv run pytest tests/test_context_builder.py -v
```

- [ ] **Step 3: Implement context_builder.py**

```python
from vault_agent.config import TAG_TAXONOMY

PARA_ZONES = {
    "02 Areas": "Ongoing responsibility with no end date (processes, personal systems, recurring work)",
    "03 Resources": "Reference material: cheatsheets, SOPs, guides, topics of interest",
    "05 Aimpress LTD": "Personal company (Aimpress) — business, finances, credentials",
}

_TAXONOMY_TEXT = "\n".join(
    f"  {prefix}/: {', '.join(values)}"
    for prefix, values in TAG_TAXONOMY.items()
)


def build_prompt(note_content: str, similar_notes: list[dict], existing_meta: dict) -> str:
    similar_block = ""
    if similar_notes:
        lines = []
        for n in similar_notes[:20]:
            lines.append(f"- [[{n['path']}]]: {n.get('snippet', '')[:200]}")
        similar_block = "## Related notes in vault\n" + "\n".join(lines)

    existing_block = ""
    if existing_meta:
        existing_block = f"## Existing frontmatter (do not repeat these in output)\n{existing_meta}\n"

    para_block = "\n".join(f"  {zone}: {desc}" for zone, desc in PARA_ZONES.items())

    return f"""You are a knowledge-base curator. Analyze the note below and return a JSON object.

{existing_block}
{similar_block}

## Note content
{note_content[:3000]}

## Tag taxonomy (use ONLY these prefixes)
{_TAXONOMY_TEXT}

## PARA folder zones
{para_block}

## Instructions
1. Infer tags from note content and context. Use only taxonomy prefixes above.
2. Suggest [[wikilinks]] to related notes from the "Related notes" list above.
   Only include links you are confident about (confidence >= 0.80).
   Use exact format: [[path/to/note]] — reason it is related.
3. Suggest the most appropriate PARA folder zone.
4. Set confidence (0.0–1.0) for your folder suggestion.

## Output format (strict JSON, no markdown)
{{
  "frontmatter": {{
    "tags": ["type/...", "domain/...", "tech/..."],
    "status": "active|paused|archived|reference",
    "type": "project|area|resource|reference|sop|meeting"
  }},
  "links": ["[[path/note]] — reason", "..."],
  "para_zone": "02 Areas|03 Resources|05 Aimpress LTD",
  "confidence": 0.87
}}
"""
```

- [ ] **Step 4: Run tests — verify PASS**

```bash
uv run pytest tests/test_context_builder.py -v
```

Expected: 4 passed.

- [ ] **Step 5: Commit**

```bash
git add vault_agent/context_builder.py tests/test_context_builder.py
git commit -m "feat: add context_builder with Scout prompt template"
```

---

### Task 6: Per-note processor (core pipeline)

**Files:**
- Create: `~/.vault-agent/vault_agent/processor.py`
- Create: `~/.vault-agent/vault_agent/state.py`
- Test: `~/.vault-agent/tests/test_processor.py`

- [ ] **Step 1: Write failing tests**

```python
# tests/test_processor.py
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from vault_agent.processor import NoteProcessor
from vault_agent.config import VAULT_PATH


@pytest.fixture
def vault(tmp_path):
    v = tmp_path / "vault"
    (v / "02 Areas").mkdir(parents=True)
    f = v / "02 Areas" / "test.md"
    f.write_text("# Test Note\n\nAbout FastAPI and Docker deployments.\n")
    return v


@pytest.fixture
def processor(vault, mock_ollama, tmp_path):
    from vault_agent.indexer import VaultIndex
    index = VaultIndex(db_path=tmp_path / "db")
    return NoteProcessor(
        vault_path=vault,
        ollama=mock_ollama,
        index=index,
        state_file=tmp_path / "state.json",
    )


def test_process_adds_frontmatter(processor, vault):
    path = vault / "02 Areas" / "test.md"
    processor.process(path)
    content = path.read_text()
    assert "---" in content
    assert "modified:" in content


def test_process_adds_related_section(processor, vault):
    path = vault / "02 Areas" / "test.md"
    # Give it a link in the mock response
    processor._ollama.generate_json.return_value = {
        "frontmatter": {"tags": ["tech/fastapi"], "status": "active", "type": "area"},
        "links": ["[[03 Resources/FastAPI Guide]] — deployment patterns"],
        "para_zone": "02 Areas",
        "confidence": 0.92,
    }
    processor.process(path)
    content = path.read_text()
    assert "## Related" in content
    assert "FastAPI Guide" in content


def test_process_skips_excluded(processor, tmp_path, vault):
    wiki = vault / "wiki"
    wiki.mkdir()
    wiki_note = wiki / "concept.md"
    wiki_note.write_text("# Wiki Note")
    # Override vault path to test exclusion
    processor._vault_path = vault
    # Should not raise, but also not modify wiki
    mtime_before = wiki_note.stat().st_mtime
    # wiki is excluded, process should return early
    result = processor.process(wiki_note)
    assert result is False


def test_process_updates_state(processor, vault, tmp_path):
    path = vault / "02 Areas" / "test.md"
    processor.process(path)
    state = json.loads((tmp_path / "state.json").read_text())
    assert any("test.md" in k for k in state.get("files", {}))
```

- [ ] **Step 2: Run — verify FAIL**

```bash
uv run pytest tests/test_processor.py -v
```

- [ ] **Step 3: Implement state.py**

```python
import json
import hashlib
from datetime import datetime
from pathlib import Path


def load_state(state_file: Path) -> dict:
    if state_file.exists():
        return json.loads(state_file.read_text())
    return {"last_run": None, "files": {}}


def save_state(state_file: Path, state: dict) -> None:
    state_file.write_text(json.dumps(state, indent=2))


def file_hash(path: Path) -> str:
    return hashlib.sha256(path.read_bytes()).hexdigest()


def is_stale(path: Path, state: dict) -> bool:
    """Return True if file changed since last processing."""
    rel = str(path)
    if rel not in state.get("files", {}):
        return True
    return state["files"][rel]["hash"] != file_hash(path)


def mark_processed(path: Path, state: dict) -> None:
    state.setdefault("files", {})[str(path)] = {
        "hash": file_hash(path),
        "processed_at": datetime.now().isoformat(),
    }
```

- [ ] **Step 4: Implement processor.py**

```python
import re
from pathlib import Path
from vault_agent.config import (
    VAULT_PATH, LINK_CONFIDENCE, MOVE_CONFIDENCE,
    EMBED_SIMILARITY, TOP_K_SIMILAR,
)
from vault_agent.frontmatter import parse_note, safe_merge, write_note, normalize_tag_syntax
from vault_agent.context_builder import build_prompt
from vault_agent.state import load_state, save_state, is_stale, mark_processed
from vault_agent.indexer import VaultIndex
from vault_agent.ollama_client import OllamaClient


def _extract_links(links_list: list[str]) -> list[str]:
    """Pull [[note]] from '[[note]] — reason' strings."""
    pattern = re.compile(r"\[\[([^\]]+)\]\]")
    return [m.group(0) for item in links_list for m in pattern.finditer(item)]


class NoteProcessor:
    def __init__(self, vault_path: Path, ollama: OllamaClient, index: VaultIndex, state_file: Path):
        self._vault_path = vault_path
        self._ollama = ollama
        self._index = index
        self._state_file = state_file
        self._state = load_state(state_file)

    def process(self, path: Path) -> bool:
        """Process one note. Returns False if skipped."""
        from vault_agent.config import is_excluded
        if is_excluded(path, self._vault_path):
            return False
        if not is_stale(path, self._state):
            return False

        # 1. Parse
        meta, body = parse_note(path)
        meta = normalize_tag_syntax(meta)

        # 2. Embed
        embedding = self._ollama.embed(f"{path.name}\n{body[:2000]}")

        # 3. Search similar
        similar_raw = self._index.search(embedding, top_k=TOP_K_SIMILAR)
        similar = [
            {"path": r.get("path", ""), "snippet": r.get("snippet", "")}
            for r in similar_raw
            if r.get("distance", 1.0) <= (1.0 - EMBED_SIMILARITY)
        ]

        # 4. Build prompt
        prompt = build_prompt(body, similar, meta)

        # 5. Call Scout
        result = self._ollama.generate_json(prompt)

        # 6. Merge frontmatter
        additions = result.get("frontmatter", {})
        if not meta.get("name"):
            additions["name"] = path.stem.replace("-", " ").title()
        if not meta.get("created"):
            from datetime import date
            additions["created"] = str(date.fromtimestamp(path.stat().st_ctime))
        merged = safe_merge(meta, additions)

        # 7. Append Related section
        links = _extract_links(result.get("links", []))
        if links and result.get("confidence", 0) >= LINK_CONFIDENCE:
            if "## Related" not in body:
                body = body.rstrip() + "\n\n## Related\n\n"
            existing = set(re.findall(r"\[\[[^\]]+\]\]", body))
            new_links = [l for l in links if l not in existing]
            if new_links:
                body = body.rstrip() + "\n" + "\n".join(f"- {l}" for l in new_links) + "\n"

        # 7b. Auto folder move — only for notes with no original frontmatter
        #     and only if Scout is confident enough
        para_zone = result.get("para_zone", "")
        confidence = result.get("confidence", 0.0)
        if (
            not meta  # no original frontmatter → new/unclassified note
            and confidence >= MOVE_CONFIDENCE
            and para_zone
            and path.parent == self._vault_path  # only from vault root
        ):
            target_dir = self._vault_path / para_zone
            target_dir.mkdir(parents=True, exist_ok=True)
            new_path = target_dir / path.name
            if not new_path.exists():
                import shutil, time
                shutil.move(str(path), str(new_path))
                time.sleep(ICLOUD_WRITE_DELAY)
                path = new_path  # continue processing at new location

        # 8. Write + update index
        write_note(path, merged, body, delay=0)
        rel = str(path.relative_to(self._vault_path))
        self._index.upsert(
            rel,
            embedding,
            {"path": rel, "snippet": body[:300]},
        )
        mark_processed(path, self._state)
        save_state(self._state_file, self._state)
        return True
```

- [ ] **Step 5: Run tests — verify PASS**

```bash
uv run pytest tests/test_processor.py -v
```

Expected: 4 passed.

- [ ] **Step 6: Commit**

```bash
git add vault_agent/processor.py vault_agent/state.py tests/test_processor.py
git commit -m "feat: add NoteProcessor 8-step pipeline"
```

---

### Task 7: Watcher + startup batch

**Files:**
- Create: `~/.vault-agent/vault_agent/watcher.py`
- Create: `~/.vault-agent/vault_agent/startup.py`
- Create: `~/.vault-agent/vault_agent/agent.py`

- [ ] **Step 1: Implement watcher.py**

```python
import time
import logging
from pathlib import Path
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler, FileModifiedEvent, FileCreatedEvent
from vault_agent.config import WATCHER_DEBOUNCE

logger = logging.getLogger(__name__)


class _VaultHandler(FileSystemEventHandler):
    def __init__(self, processor, debounce: float = WATCHER_DEBOUNCE):
        self._processor = processor
        self._debounce = debounce
        self._pending: dict[str, float] = {}

    def _schedule(self, path_str: str) -> None:
        self._pending[path_str] = time.monotonic() + self._debounce

    def on_modified(self, event):
        if not event.is_directory and event.src_path.endswith(".md"):
            self._schedule(event.src_path)

    def on_created(self, event):
        if not event.is_directory and event.src_path.endswith(".md"):
            self._schedule(event.src_path)

    def flush_due(self) -> None:
        now = time.monotonic()
        due = [p for p, t in self._pending.items() if now >= t]
        for p in due:
            del self._pending[p]
            try:
                self._processor.process(Path(p))
            except Exception:
                logger.exception("Error processing %s", p)


class VaultWatcher:
    def __init__(self, vault_path: Path, processor):
        self._vault_path = vault_path
        self._handler = _VaultHandler(processor)
        self._observer = Observer()

    def start(self) -> None:
        self._observer.schedule(self._handler, str(self._vault_path), recursive=True)
        self._observer.start()
        logger.info("Watching %s", self._vault_path)

    def tick(self) -> None:
        self._handler.flush_due()

    def stop(self) -> None:
        self._observer.stop()
        self._observer.join()
```

- [ ] **Step 2: Implement startup.py**

```python
import time
import logging
import psutil
from datetime import datetime
from pathlib import Path
from vault_agent.config import (
    VAULT_PATH, IDLE_CPU_THRESHOLD, IDLE_SUSTAINED_S, IDLE_NOTE_INTERVAL,
)
from vault_agent.state import load_state
from vault_agent.config import is_excluded

logger = logging.getLogger(__name__)


def build_catchup_queue(vault_path: Path, state_file: Path) -> list[Path]:
    """Return all .md files modified since last_run that need processing."""
    state = load_state(state_file)
    last_run_str = state.get("last_run")
    last_run_ts = datetime.fromisoformat(last_run_str).timestamp() if last_run_str else 0.0

    queue = []
    for path in vault_path.rglob("*.md"):
        if is_excluded(path):
            continue
        if path.stat().st_mtime > last_run_ts:
            queue.append(path)
    logger.info("Catchup queue: %d notes", len(queue))
    return queue


def is_idle() -> bool:
    """Return True if CPU has been < threshold for IDLE_SUSTAINED_S seconds."""
    samples = int(IDLE_SUSTAINED_S / 1.0)
    for _ in range(samples):
        if psutil.cpu_percent(interval=1.0) > IDLE_CPU_THRESHOLD:
            return False
    return True


def process_queue(queue: list[Path], processor) -> None:
    """Process a list of notes at idle speed."""
    for path in queue:
        try:
            processor.process(path)
        except Exception:
            logger.exception("Idle processor error: %s", path)
        time.sleep(IDLE_NOTE_INTERVAL)
```

- [ ] **Step 3: Add psutil to deps**

```bash
cd ~/.vault-agent && uv add psutil
```

- [ ] **Step 4: Implement agent.py**

```python
import time
import logging
import logging.handlers
import signal
import sys
from datetime import datetime
from pathlib import Path
from vault_agent.config import VAULT_PATH, DB_DIR, STATE_FILE, LOG_FILE, ICLOUD_WRITE_DELAY
from vault_agent.ollama_client import OllamaClient
from vault_agent.indexer import VaultIndex
from vault_agent.processor import NoteProcessor
from vault_agent.watcher import VaultWatcher
from vault_agent.startup import build_catchup_queue, is_idle, process_queue
from vault_agent.state import load_state, save_state


def _setup_logging():
    handler = logging.handlers.RotatingFileHandler(
        LOG_FILE, maxBytes=10 * 1024 * 1024, backupCount=2
    )
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s %(levelname)s %(name)s — %(message)s",
        handlers=[handler, logging.StreamHandler()],
    )


def main():
    _setup_logging()
    logger = logging.getLogger("vault-agent")
    logger.info("vault-agent starting")

    ollama  = OllamaClient()
    index   = VaultIndex()
    processor = NoteProcessor(VAULT_PATH, ollama, index, STATE_FILE)
    watcher   = VaultWatcher(VAULT_PATH, processor)

    # Catch-up batch on startup
    queue = build_catchup_queue(VAULT_PATH, STATE_FILE)
    if queue:
        logger.info("Processing %d catch-up notes", len(queue))
        process_queue(queue, processor)

    # Update last_run
    state = load_state(STATE_FILE)
    state["last_run"] = datetime.now().isoformat()
    save_state(STATE_FILE, state)

    # Start watcher
    watcher.start()

    def _shutdown(sig, frame):
        logger.info("Shutting down")
        watcher.stop()
        ollama.close()
        sys.exit(0)

    signal.signal(signal.SIGTERM, _shutdown)
    signal.signal(signal.SIGINT, _shutdown)

    idle_queue: list[Path] = []
    last_idle_check = time.monotonic()

    while True:
        watcher.tick()

        # Every 5 min: check for notes to process in background
        if time.monotonic() - last_idle_check > 300:
            if is_idle():
                from vault_agent.config import is_excluded
                from vault_agent.state import is_stale
                idle_queue = [
                    p for p in VAULT_PATH.rglob("*.md")
                    if not is_excluded(p) and is_stale(p, load_state(STATE_FILE))
                ]
                if idle_queue:
                    logger.info("Idle: processing %d backlog notes", len(idle_queue))
                    process_queue(idle_queue[:50], processor)  # max 50 per cycle
            last_idle_check = time.monotonic()

        time.sleep(0.5)


if __name__ == "__main__":
    main()
```

- [ ] **Step 5: Smoke test (Ollama must be running)**

```bash
cd ~/.vault-agent
# First pull models if not done
ollama pull llama4:scout
ollama pull qwen3-embed:0.6b

# Run agent for 10 seconds
uv run timeout 10 vault-agent || true
```

Expected: logs show "vault-agent starting", "Watching ...", no errors.

- [ ] **Step 6: Commit**

```bash
git add vault_agent/watcher.py vault_agent/startup.py vault_agent/agent.py
git commit -m "feat: add watcher, startup batch, and main agent loop"
```

---

### Task 8: Initial vault indexing

**Files:**
- Create: `~/.vault-agent/vault_agent/initial_index.py`

- [ ] **Step 1: Implement initial_index.py**

```python
import logging
from pathlib import Path
from vault_agent.config import VAULT_PATH, is_excluded
from vault_agent.frontmatter import parse_note
from vault_agent.indexer import VaultIndex
from vault_agent.ollama_client import OllamaClient

logger = logging.getLogger(__name__)


def run_initial_index(vault_path: Path = VAULT_PATH) -> None:
    """
    Index all non-excluded .md files into ChromaDB.
    Safe to re-run — skips already-indexed files.
    """
    ollama = OllamaClient()
    index  = VaultIndex()
    existing = index.existing_ids()

    notes = [p for p in vault_path.rglob("*.md") if not is_excluded(p)]
    logger.info("Indexing %d notes (%d already in DB)", len(notes), len(existing))

    for i, path in enumerate(notes):
        rel = str(path.relative_to(vault_path))
        if rel in existing:
            continue
        try:
            _, body = parse_note(path)
            embedding = ollama.embed(f"{path.name}\n{body[:2000]}")
            index.upsert(rel, embedding, {"path": rel, "snippet": body[:300]})
            if i % 50 == 0:
                logger.info("Indexed %d / %d", i, len(notes))
        except Exception:
            logger.exception("Failed to index %s", path)

    logger.info("Initial indexing complete")
    ollama.close()


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)
    run_initial_index()
```

- [ ] **Step 2: Run initial indexing**

```bash
cd ~/.vault-agent
uv run python -m vault_agent.initial_index
```

Expected: logs show progress every 50 notes, completes in 15-30 min.

- [ ] **Step 3: Commit**

```bash
git add vault_agent/initial_index.py
git commit -m "feat: add initial vault indexing script"
```

---

## Phase 2 — Cleanup + Manuals (Tasks 9–10)

### Task 9: Vault cleanup reporter

**Files:**
- Create: `~/.vault-agent/vault_agent/cleanup.py`
- Test: `~/.vault-agent/tests/test_cleanup.py`

- [ ] **Step 1: Write failing tests**

```python
# tests/test_cleanup.py
from pathlib import Path
from vault_agent.cleanup import find_empty_notes, find_orphaned_attachments, build_cleanup_report


def test_find_empty_notes(tmp_path):
    (tmp_path / "empty.md").write_text("---\nstatus: active\n---\n")
    (tmp_path / "real.md").write_text("---\nstatus: active\n---\n\n# Title\n\nSome real content here.")
    empties = find_empty_notes(tmp_path)
    assert any("empty.md" in str(p) for p in empties)
    assert not any("real.md" in str(p) for p in empties)


def test_find_orphaned_attachments(tmp_path):
    (tmp_path / "unused.png").write_bytes(b"fake")
    (tmp_path / "note.md").write_text("# No image references")
    orphans = find_orphaned_attachments(tmp_path)
    assert any("unused.png" in str(p) for p in orphans)


def test_build_cleanup_report_creates_file(tmp_path):
    report_path = tmp_path / "Vault Cleanup.md"
    build_cleanup_report(
        empty_notes=[tmp_path / "empty.md"],
        orphaned_attachments=[tmp_path / "unused.png"],
        output_path=report_path,
    )
    content = report_path.read_text()
    assert "empty.md" in content
    assert "unused.png" in content
    assert "## Empty" in content
```

- [ ] **Step 2: Run — verify FAIL**

```bash
uv run pytest tests/test_cleanup.py -v
```

- [ ] **Step 3: Implement cleanup.py**

```python
import re
from datetime import date
from pathlib import Path
from vault_agent.config import VAULT_PATH, is_excluded
from vault_agent.frontmatter import parse_note

ATTACHMENT_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".pdf", ".mp4", ".webp"}
EMPTY_THRESHOLD = 100  # chars of body content


def find_empty_notes(vault_path: Path = VAULT_PATH) -> list[Path]:
    empty = []
    for p in vault_path.rglob("*.md"):
        if is_excluded(p):
            continue
        try:
            _, body = parse_note(p)
            if len(body.strip()) < EMPTY_THRESHOLD:
                empty.append(p)
        except Exception:
            pass
    return empty


def find_orphaned_attachments(vault_path: Path = VAULT_PATH) -> list[Path]:
    """Find attachments not referenced in any .md file."""
    all_content = " ".join(
        p.read_text(errors="ignore")
        for p in vault_path.rglob("*.md")
    )
    orphans = []
    for p in vault_path.iterdir():
        if p.suffix.lower() in ATTACHMENT_EXTS:
            if p.name not in all_content and p.stem not in all_content:
                orphans.append(p)
    return orphans


def build_cleanup_report(
    empty_notes: list[Path],
    orphaned_attachments: list[Path],
    output_path: Path,
) -> None:
    today = str(date.today())
    lines = [
        f"---\nname: Vault Cleanup {today}\nstatus: active\ntype: reference\n"
        f"tags:\n  - type/reference\ncreated: {today}\nmodified: {today}\n---\n",
        f"# Vault Cleanup — {today}\n",
    ]
    if empty_notes:
        lines.append(f"\n## Empty / Stub Notes ({len(empty_notes)})\n")
        for p in empty_notes:
            lines.append(f"- [[{p}]]\n")
    if orphaned_attachments:
        total_mb = sum(p.stat().st_size for p in orphaned_attachments) / 1024 / 1024
        lines.append(f"\n## Orphaned Attachments ({len(orphaned_attachments)} files, {total_mb:.1f} MB)\n")
        for p in orphaned_attachments:
            kb = p.stat().st_size / 1024
            lines.append(f"- `{p.name}` ({kb:.0f} KB)\n")
    output_path.write_text("".join(lines))
```

- [ ] **Step 4: Run tests — verify PASS**

```bash
uv run pytest tests/test_cleanup.py -v
```

Expected: 3 passed.

- [ ] **Step 5: Wire cleanup into agent.py — weekly scan**

In `agent.py`, add to the main loop (after startup batch):

```python
# Add at top of main():
from vault_agent.cleanup import find_empty_notes, find_orphaned_attachments, build_cleanup_report
from vault_agent.config import VAULT_PATH
import json

CLEANUP_REPORT_PATH = VAULT_PATH / "02 Areas" / "Vault Cleanup.md"

# After state["last_run"] update, add:
last_cleanup = state.get("last_cleanup")
needs_cleanup = (
    last_cleanup is None or
    (datetime.now() - datetime.fromisoformat(last_cleanup)).days >= 7
)
if needs_cleanup:
    logger.info("Running weekly cleanup scan")
    empty = find_empty_notes(VAULT_PATH)
    orphans = find_orphaned_attachments(VAULT_PATH)
    build_cleanup_report(empty, orphans, CLEANUP_REPORT_PATH)
    state["last_cleanup"] = datetime.now().isoformat()
    save_state(STATE_FILE, state)
```

- [ ] **Step 6: Commit**

```bash
git add vault_agent/cleanup.py tests/test_cleanup.py vault_agent/agent.py
git commit -m "feat: add cleanup scanner and weekly Vault Cleanup.md report"
```

---

### Task 10: Project manuals generator

**Files:**
- Create: `~/.vault-agent/vault_agent/manuals.py`
- Test: `~/.vault-agent/tests/test_manuals.py`

- [ ] **Step 1: Write failing tests**

```python
# tests/test_manuals.py
from pathlib import Path
from unittest.mock import MagicMock, patch
from vault_agent.manuals import scan_projects, generate_manual, ManualsGenerator


def test_scan_projects_finds_local_path(tmp_path):
    project_dir = tmp_path / "01 Projects" / "my-tool"
    project_dir.mkdir(parents=True)
    note = project_dir / "My Tool.md"
    note.write_text("---\nlocal_path: /Users/ai_leed/Projects/my-tool\nstatus: active\n---\n# My Tool")
    projects = scan_projects(tmp_path)
    assert len(projects) == 1
    assert projects[0]["local_path"] == "/Users/ai_leed/Projects/my-tool"


def test_scan_projects_skips_missing_local_path(tmp_path):
    project_dir = tmp_path / "01 Projects" / "no-path"
    project_dir.mkdir(parents=True)
    (project_dir / "No Path.md").write_text("---\nstatus: active\n---\n# No Path")
    projects = scan_projects(tmp_path)
    assert len(projects) == 0


def test_generate_manual_writes_file(tmp_path, mock_ollama):
    mock_ollama.generate_json.return_value = {
        "user_manual": "# User Manual\n\n## What This Does\nA test tool.",
        "developer_manual": "# Developer Manual\n\n## Setup\nRun docker-compose up.",
    }
    gen = ManualsGenerator(ollama=mock_ollama)
    project_dir = tmp_path / "01 Projects" / "test-tool"
    project_dir.mkdir(parents=True)
    gen.write_manuals(
        project_dir=project_dir,
        project_name="Test Tool",
        codebase_summary="FastAPI backend, React frontend.",
    )
    assert (project_dir / "USER_MANUAL.md").exists()
    assert (project_dir / "DEVELOPER_MANUAL.md").exists()
    assert "What This Does" in (project_dir / "USER_MANUAL.md").read_text()
```

- [ ] **Step 2: Run — verify FAIL**

```bash
uv run pytest tests/test_manuals.py -v
```

- [ ] **Step 3: Implement manuals.py**

```python
import subprocess
import logging
from datetime import date
from pathlib import Path
from vault_agent.config import VAULT_PATH, ALLOWED_IN_PROJECTS
from vault_agent.frontmatter import parse_note
from vault_agent.ollama_client import OllamaClient

logger = logging.getLogger(__name__)

SOURCE_EXTS = {".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".yaml", ".yml", ".toml"}
MAX_FILES_PER_PROJECT = 30
MAX_CHARS_PER_FILE = 3000


def scan_projects(vault_path: Path = VAULT_PATH) -> list[dict]:
    """Return list of {name, project_dir, local_path, note_path} for active projects."""
    projects_dir = vault_path / "01 Projects"
    results = []
    for project_dir in projects_dir.iterdir():
        if not project_dir.is_dir():
            continue
        for md in project_dir.glob("*.md"):
            if md.name in ALLOWED_IN_PROJECTS:
                continue
            try:
                meta, _ = parse_note(md)
                local_path = meta.get("local_path")
                status = meta.get("status", "active")
                if local_path and status in ("active", "production"):
                    results.append({
                        "name": meta.get("name", project_dir.name),
                        "project_dir": project_dir,
                        "local_path": local_path,
                        "note_path": md,
                    })
                    break
            except Exception:
                pass
    return results


def _read_codebase(local_path: str) -> str:
    """Read key source files from local codebase, return summary text."""
    code_dir = Path(local_path)
    if not code_dir.exists():
        return ""

    files: list[Path] = []
    for ext in SOURCE_EXTS:
        files.extend(code_dir.rglob(f"*{ext}"))

    # Filter out node_modules, venv, __pycache__, dist
    skip = {"node_modules", ".venv", "venv", "__pycache__", "dist", "build", ".git"}
    files = [
        f for f in files
        if not any(part in skip for part in f.parts)
    ][:MAX_FILES_PER_PROJECT]

    chunks = []
    for f in files:
        try:
            content = f.read_text(errors="ignore")[:MAX_CHARS_PER_FILE]
            rel = f.relative_to(code_dir)
            chunks.append(f"### {rel}\n```\n{content}\n```")
        except Exception:
            pass

    # Also read README and docker-compose
    for name in ["README.md", "docker-compose.yml", "docker-compose.yaml", ".env.example"]:
        p = code_dir / name
        if p.exists():
            chunks.insert(0, f"### {name}\n```\n{p.read_text(errors='ignore')[:2000]}\n```")

    return "\n\n".join(chunks)


def _has_new_commits(local_path: str, since_date: str) -> bool:
    """Return True if git repo has commits after since_date."""
    try:
        result = subprocess.run(
            ["git", "log", f"--after={since_date}", "--oneline"],
            cwd=local_path, capture_output=True, text=True, timeout=5,
        )
        return bool(result.stdout.strip())
    except Exception:
        return True  # assume yes if we can't check


class ManualsGenerator:
    def __init__(self, ollama: OllamaClient):
        self._ollama = ollama

    def write_manuals(self, project_dir: Path, project_name: str, codebase_summary: str) -> None:
        today = str(date.today())
        prompt = f"""Analyze this codebase and produce two documents as JSON.

## Project: {project_name}

## Codebase
{codebase_summary[:15000]}

## Output format (strict JSON)
{{
  "user_manual": "Full markdown content for USER_MANUAL.md — covers: What This Tool Does, Who Uses It, How to Access, Main Workflows (step-by-step), FAQ",
  "developer_manual": "Full markdown content for DEVELOPER_MANUAL.md — covers: Architecture Overview, Tech Stack, Local Setup, Environment Variables, Key Services & Entry Points, API Reference, Deployment, Known Gotchas"
}}
"""
        result = self._ollama.generate_json(prompt)
        fm_header = (
            f"---\nauto_generated: true\nmanual_updated_at: {today}\n"
            f"modified: {today}\n---\n\n"
        )
        user_md    = project_dir / "USER_MANUAL.md"
        dev_md     = project_dir / "DEVELOPER_MANUAL.md"
        user_md.write_text(fm_header + result.get("user_manual", ""), encoding="utf-8")
        dev_md.write_text(fm_header + result.get("developer_manual", ""), encoding="utf-8")
        logger.info("Wrote manuals for %s", project_name)

    def run_all(self, vault_path: Path = VAULT_PATH) -> None:
        projects = scan_projects(vault_path)
        logger.info("Found %d active projects with local_path", len(projects))
        for proj in projects:
            user_md = proj["project_dir"] / "USER_MANUAL.md"
            # Skip if manual is fresh (< 7 days) and no new commits
            if user_md.exists():
                meta, _ = parse_note(user_md)
                updated = meta.get("manual_updated_at", "2000-01-01")
                if not _has_new_commits(proj["local_path"], updated):
                    logger.debug("Skipping %s — no new commits", proj["name"])
                    continue
            logger.info("Generating manuals for %s", proj["name"])
            codebase = _read_codebase(proj["local_path"])
            if not codebase:
                logger.warning("No source files found at %s", proj["local_path"])
                continue
            self.write_manuals(proj["project_dir"], proj["name"], codebase)
```

- [ ] **Step 4: Run tests — verify PASS**

```bash
uv run pytest tests/test_manuals.py -v
```

Expected: 3 passed.

- [ ] **Step 5: Wire into agent.py — weekly run**

In `agent.py` main(), after cleanup section:

```python
from vault_agent.manuals import ManualsGenerator

# After cleanup block:
needs_manuals = (
    state.get("last_manuals") is None or
    (datetime.now() - datetime.fromisoformat(state["last_manuals"])).days >= 7
)
if needs_manuals:
    logger.info("Running project manuals generation")
    gen = ManualsGenerator(ollama=ollama)
    gen.run_all(VAULT_PATH)
    state["last_manuals"] = datetime.now().isoformat()
    save_state(STATE_FILE, state)
```

- [ ] **Step 6: Commit**

```bash
git add vault_agent/manuals.py tests/test_manuals.py vault_agent/agent.py
git commit -m "feat: add project manuals generator (USER_MANUAL + DEVELOPER_MANUAL)"
```

---

## Phase 3 — LaunchAgent (Task 11)

### Task 11: macOS autostart

**Files:**
- Create: `~/.vault-agent/plist/com.vadym.vault-agent.plist`
- Create: `~/.vault-agent/install.sh`

- [ ] **Step 1: Write plist**

```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.vadym.vault-agent</string>
    <key>ProgramArguments</key>
    <array>
        <string>/Users/ai_leed/.vault-agent/.venv/bin/vault-agent</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
    <key>StandardOutPath</key>
    <string>/Users/ai_leed/.vault-agent/vault-agent.log</string>
    <key>StandardErrorPath</key>
    <string>/Users/ai_leed/.vault-agent/vault-agent.log</string>
    <key>EnvironmentVariables</key>
    <dict>
        <key>PATH</key>
        <string>/usr/local/bin:/usr/bin:/bin</string>
    </dict>
</dict>
</plist>
```

- [ ] **Step 2: Write install.sh**

```bash
#!/bin/bash
set -e
PLIST_SRC="$HOME/.vault-agent/plist/com.vadym.vault-agent.plist"
PLIST_DST="$HOME/Library/LaunchAgents/com.vadym.vault-agent.plist"

echo "Installing vault-agent LaunchAgent..."
cp "$PLIST_SRC" "$PLIST_DST"
launchctl load "$PLIST_DST"
echo "Done. vault-agent will start automatically at login."
echo "Check status: launchctl list | grep vault-agent"
echo "View logs:    tail -f ~/.vault-agent/vault-agent.log"
```

```bash
chmod +x ~/.vault-agent/install.sh
```

- [ ] **Step 3: Run initial index first (one-time setup)**

```bash
cd ~/.vault-agent
ollama serve &   # if not already running
uv run python -m vault_agent.initial_index
```

Wait for completion (20-40 min for 1236 notes).

- [ ] **Step 4: Install LaunchAgent**

```bash
~/.vault-agent/install.sh
```

Expected: `Done. vault-agent will start automatically at login.`

- [ ] **Step 5: Verify running**

```bash
launchctl list | grep vault-agent
tail -20 ~/.vault-agent/vault-agent.log
```

Expected: PID in launchctl output, logs show "Watching ..." and no errors.

- [ ] **Step 6: Final commit**

```bash
cd ~/.vault-agent
git add plist/ install.sh
git commit -m "feat: add LaunchAgent plist and install script"
```

---

## Full Test Suite

```bash
cd ~/.vault-agent
uv run pytest -v --tb=short
```

Expected: all tests pass.

---

## Manual Smoke Test After Install

1. Open Obsidian, create new note in `03 Resources/`:
   ```markdown
   # FastAPI Rate Limiting
   
   Notes about implementing rate limiting in FastAPI using Redis.
   ```
2. Save the file
3. Wait 5 seconds
4. Reopen the note — should have frontmatter with `tags`, `status`, `type`, `modified` and a `## Related` section with links to similar notes
5. Check `~/.vault-agent/vault-agent.log` for the processing entry
