---
phase: 060-setup-modularization
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - paperforge/setup/__init__.py
  - paperforge/setup/checker.py
  - paperforge/setup/config_writer.py
autonomous: true
requirements:
  - SETP-02
  - SETP-06

must_haves:
  truths:
    - "SetupStepResult dataclass exists with {step, ok, message, error} fields — used by all setup classes for per-step status reporting"
    - "AGENT_CONFIGS is defined as a module-level constant accessible from paperforge.setup and re-exported from setup_wizard.py for backward compat"
    - "SetupChecker validates Python >= 3.10 before any install step — produces classified SetupStepResult with ErrorCode on failure"
    - "SetupChecker validates pip availability and dependency health (requests, pymupdf, pillow) — returns structured error on missing deps"
    - "SetupChecker validates vault directory structure, Zotero installation, Better BibTeX presence, and JSON export validity — all returning SetupStepResult"
    - "ConfigWriter writes paperforge.json atomically using tempfile.NamedTemporaryFile + os.replace — no partial write survives crash"
    - "ConfigWriter creates domain-collections.json from existing export JSONs — non-atomic acceptable for this file"
    - "Existing tests importing CheckResult, EnvChecker, AGENT_CONFIGS from paperforge.setup_wizard continue to pass via backward-compat re-exports"
  artifacts:
    - path: "paperforge/setup/__init__.py"
      provides: "SetupStepResult dataclass, AGENT_CONFIGS constant, package exports"
      min_lines: 60
    - path: "paperforge/setup/checker.py"
      provides: "SetupChecker class — all precondition validation methods"
      min_lines: 150
    - path: "paperforge/setup/config_writer.py"
      provides: "ConfigWriter class — atomic paperforge.json writes"
      min_lines: 60
  key_links:
    - from: "paperforge/setup/__init__.py"
      to: "paperforge/setup_wizard.py"
      via: "backward-compat re-export"
      pattern: "from paperforge.setup import"
    - from: "paperforge/setup/checker.py"
      to: "paperforge/core/errors.py"
      via: "import ErrorCode for classification"
      pattern: "from paperforge.core.errors import ErrorCode"
    - from: "paperforge/setup/config_writer.py"
      to: "tempfile + os.replace"
      via: "atomic write pattern"
      pattern: "os\\.replace"

---

<objective>
Create the core contract layer for setup modularization: the `SetupStepResult` dataclass that all setup classes use for per-step status reporting, the `AGENT_CONFIGS` platform definitions, the `SetupChecker` class (extracted from `EnvChecker`), and the `ConfigWriter` class for atomic config file writes.

Purpose: Define the shared type contract and precondition layer that every other setup class depends on. Without `SetupStepResult`, no step can report status. Without `SetupChecker`, no step can verify preconditions. Without `ConfigWriter`, config files risk corruption on crash.

Output:
- `paperforge/setup/__init__.py` — `SetupStepResult` dataclass + `AGENT_CONFIGS` dict re-export
- `paperforge/setup/checker.py` — `SetupChecker` class with all check methods
- `paperforge/setup/config_writer.py` — `ConfigWriter` class with atomic write pattern
</objective>

<execution_context>
../.opencode/get-shit-done/workflows/execute-plan.md
../.opencode/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/060-setup-modularization/060-CONTEXT.md

# Phase 57 contract types — SetupStepResult follows the same pattern
@paperforge/core/result.py
@paperforge/core/errors.py

# Existing code being decomposed
@paperforge/setup_wizard.py

# Existing tests that MUST continue passing
@tests/test_setup_wizard.py
</context>

<interfaces>
Key types from Phase 57 contracts used by setup classes:

From paperforge/core/errors.py:
```python
class ErrorCode(str, Enum):
    PYTHON_NOT_FOUND = "PYTHON_NOT_FOUND"
    VERSION_MISMATCH = "VERSION_MISMATCH"
    BBT_EXPORT_NOT_FOUND = "BBT_EXPORT_NOT_FOUND"
    OCR_TOKEN_MISSING = "OCR_TOKEN_MISSING"
    SYNC_FAILED = "SYNC_FAILED"
    VALIDATION_ERROR = "VALIDATION_ERROR"
    INTERNAL_ERROR = "INTERNAL_ERROR"
    UNKNOWN = "UNKNOWN"
```

From paperforge/core/result.py:
```python
@dataclass
class PFError:
    code: ErrorCode
    message: str
    details: dict = field(default_factory=dict)
```

Existing AGENT_CONFIGS structure (from setup_wizard.py):
```python
AGENT_CONFIGS = {
    "opencode": {
        "name": "OpenCode",
        "skill_dir": ".opencode/skills",
        "command_dir": ".opencode/command",
        "format": "flat_command",
        "prefix": "/",
        "config_file": None,
    },
    "claude": { "name": "Claude Code", "skill_dir": ".claude/skills", "format": "skill_directory", ... },
    # ... 9 total platforms
}
```

Existing CheckResult class being replaced:
```python
class CheckResult:
    def __init__(self, name: str):
        self.name = name
        self.passed = False
        self.detail = ""
        self.action_required = False
```
</interfaces>

<tasks>

<task type="auto">
  <name>Task 1: Create SetupStepResult dataclass and AGENT_CONFIGS in paperforge/setup/__init__.py</name>
  <files>paperforge/setup/__init__.py</files>
  <action>
    Create the `paperforge/setup/__init__.py` package with:

    1. **`SetupStepResult` dataclass** with fields:
       - `step: str` — step identifier (e.g., "checker", "writer", "vault", "runtime", "agent", "plan")
       - `ok: bool` — success/failure
       - `message: str` — human-readable status message
       - `error: PFError | None = None` — optional classified error from Phase 57 ErrorCode
    
    2. **`AGENT_CONFIGS`** dict — exact copy from `setup_wizard.py` lines 37-102, preserving all 9 platforms with identical structure.
    
    3. **`StepStatus`** convenience class method or helper on `SetupStepResult`:
       - `@classmethod def ok(cls, step: str, message: str) -> Self` — shorthard for successful step
       - `@classmethod def fail(cls, step: str, message: str, error: PFError | None = None) -> Self` — shorthand for failed step
    
    4. **Package exports**:
       - `__all__` listing `SetupStepResult`, `AGENT_CONFIGS`

    Use `from __future__ import annotations`. Import `PFError` from `paperforge.core.errors`. Import `ErrorCode` from `paperforge.core.errors`.

    Do NOT add any business logic beyond the dataclass and factory methods.
  </action>
  <verify>
    <automated>python -c "from paperforge.setup import SetupStepResult, AGENT_CONFIGS; r = SetupStepResult.ok('test', 'ok'); assert r.ok and r.step == 'test'; r2 = SetupStepResult.fail('test', 'fail'); assert not r2.ok"</automated>
  </verify>
  <done>SetupStepResult dataclass created with ok/fail class methods and serializable fields; AGENT_CONFIGS preserves all 9 platforms; __all__ properly declared</done>
</task>

<task type="auto">
  <name>Task 2: Create SetupChecker class from EnvChecker decomposition</name>
  <files>paperforge/setup/checker.py</files>
  <action>
    Create `paperforge/setup/checker.py` with the `SetupChecker` class. This extracts all check methods from `EnvChecker` (lines 118-402 of setup_wizard.py) into a standalone class with explicit interface, replacing `CheckResult` returns with `SetupStepResult`.

    **Class: `SetupChecker`**
    - `__init__(self, vault: Path)`
    - `system_dir: str = "System"` — configurable directory name
    - `manual_zotero_path: Path | None = None` — optional override for Zotero path

    **Methods** (each returns `SetupStepResult` with step="checker"):
    - `check_python(self) -> SetupStepResult`
      - Returns `ok=True` with message `"Python X.Y.Z found"` if version >= 3.10
      - Returns `ok=False` with ErrorCode.VERSION_MISMATCH if below 3.10
      - Uses `sys.version_info` — no subprocess call

    - `check_dependencies(self) -> SetupStepResult`
      - Tests imports for `requests`, `pymupdf` (import as `fitz`), `PIL`
      - Returns `ok=True` with message listing found deps
      - Returns `ok=False` with ErrorCode.INTERNAL_ERROR listing missing deps

    - `check_vault(self) -> SetupStepResult`
      - Checks vault/<system_dir>/PaperForge/exports and ocr exist
      - Returns `ok=False` with ErrorCode.VALIDATION_ERROR listing missing dirs

    - `check_zotero(self, manual_path: Path | None = None) -> SetupStepResult`
      - Calls `_find_zotero(manual_path)` (private helper)
      - Returns `ok=True` with Zotero path as message
      - Returns `ok=False` with ErrorCode.INTERNAL_ERROR if not found

    - `check_bbt(self, manual_path: Path | None = None) -> SetupStepResult`
      - Searches for Better BibTeX extension files
      - Platform-aware: Windows uses APPDATA, macOS uses ~/Library, Linux uses ~/.zotero
      - Returns `ok=True` with BBT detail or `ok=False` with ErrorCode.BBT_EXPORT_NOT_FOUND

    - `check_json(self) -> SetupStepResult`
      - Checks exports_dir for *.json files with valid Better BibTeX content (dict with "items" or non-empty list)
      - Returns `ok=True` with count of valid JSONs or `ok=False` with descriptive error

    - `get_exports_dir(self) -> Path`
      - Returns `vault / system_dir / "PaperForge" / "exports"` (same logic as current EnvChecker)

    - `_find_zotero(self, manual_path: Path | None = None) -> Path | None` (private)
      - Exact same search logic as current `EnvChecker._find_zotero()` (lines 191-269)
      - Full platform detection: Windows (registry + common paths + `where`), macOS (common paths + `which`), Linux (common paths + `which`)

    **Backward compat**: In setup_wizard.py, add these imports at the bottom:
    ```python
    from paperforge.setup import SetupStepResult
    from paperforge.setup.checker import SetupChecker as EnvChecker
    
    class CheckResult:
        """Backward-compat wrapper — delegates to SetupStepResult."""
        def __init__(self, name: str):
            self.name = name
            self.passed = False
            self.detail = ""
            self.action_required = False
    ```
    These are temporary shims so existing tests in `tests/test_setup_wizard.py` continue passing.

    **Edge cases:**
    - _find_zotero handles all 4 search strategies (registry, common paths, where/which, user-provided manual_path)
    - check_bbt handles 3 platform-specific search paths
    - check_json handles empty exports dir, invalid JSON, valid but empty item lists
  </action>
  <verify>
    <automated>python -c "from paperforge.setup.checker import SetupChecker; import tempfile, os; d = tempfile.mkdtemp(); from pathlib import Path; c = SetupChecker(Path(d)); r = c.check_python(); assert r.ok; r2 = c.check_vault(); assert not r2.ok; r3 = c.check_json(); assert not r3.ok"</automated>
  </verify>
  <done>SetupChecker class extracted with all 6 check methods returning SetupStepResult; backward-compat CheckResult class re-exported from setup_wizard.py; _find_zotero search logic preserved identically</done>
</task>

<task type="auto">
  <name>Task 3: Create ConfigWriter class with atomic write pattern</name>
  <files>paperforge/setup/config_writer.py</files>
  <action>
    Create `paperforge/setup/config_writer.py` with the `ConfigWriter` class. This extracts the inline paperforge.json and domain-collections.json writing logic from `headless_setup()` (lines 935-984 of setup_wizard.py) into a focused class.

    **Class: `ConfigWriter`**
    - `__init__(self, vault: Path, system_dir: str = "System")`
    - Stores paths for paperforge.json at `vault/paperforge.json` and domain config at `vault / system_dir / "PaperForge" / "config" / "domain-collections.json"`

    **Methods:**

    - `write_paperforge_json(self, config: dict) -> SetupStepResult`
      - Writes `paperforge.json` using atomic pattern:
        1. Create `tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', delete=False, dir=vault_dir)`
        2. Write `json.dumps(config, indent=2, ensure_ascii=False)`
        3. Flush and close temp file
        4. Call `os.replace(tmp_path, target_path)` — atomic on POSIX and Windows
      - Returns `SetupStepResult.ok("writer", "paperforge.json written")` on success
      - Returns `SetupStepResult.fail(...)` with ErrorCode.INTERNAL_ERROR on failure
      - **CRITICAL**: Do NOT use `json_dump` directly to the target path. Must use tempfile + os.replace for atomicity per success criterion 3.
      - Must handle PermissionError, OSError, json.JSONEncodeError gracefully

    - `build_config(self, version: str, agent_name: str, agent_key: str, skill_dir: str, command_dir: str, system_dir: str, zotero_data: str, vault_config: dict) -> dict`
      - Builds the config dict matching the current format:
        ```python
        {
            "version": version,
            "schema_version": "2",
            "agent_platform": agent_name,
            "agent_key": agent_key,
            "skill_dir": skill_dir,
            "command_dir": command_dir,
            "paperforge_path": f"{system_dir}/PaperForge",
            "zotero_data_dir": zotero_data,
            "zotero_link": f"{system_dir}/Zotero",
            "vault_config": vault_config,
        }
        ```
      - Reads existing config if it exists and merges `version` from it (preserving user's version if set)

    - `write_domain_config(self) -> SetupStepResult`
      - Creates `domain-collections.json` from existing export JSON filenames
      - Structure: `{"domains": [{"domain": stem, "export_file": name, "allowed_collections": []}]}`
      - Only writes if file doesn't exist (incremental)
      - Returns `SetupStepResult.ok(...)` or `SetupStepResult.fail(...)`

    **Edge cases:**
    - Temp file cleanup on crash: try/finally with `os.unlink(tmp_path.name)` in except handler
    - Existing paperforge.json read failure: treat as empty dict, don't crash
    - JSON encoding errors: catch and return classified error
  </action>
  <verify>
    <automated>python -c "from paperforge.setup.config_writer import ConfigWriter; import tempfile, os; d = tempfile.mkdtemp(); from pathlib import Path; w = ConfigWriter(Path(d)); r = w.write_paperforge_json({'test': True}); assert r.ok; assert (Path(d) / 'paperforge.json').exists(); import json; assert json.loads((Path(d) / 'paperforge.json').read_text()) == {'test': True}"</automated>
  </verify>
  <done>ConfigWriter created with atomic tempfile+os.replace write pattern; build_config constructs canonical config dict; write_domain_config creates incremental domain-collections.json; all edge cases handled</done>
</task>

</tasks>

<verification>
1. `python -c "from paperforge.setup import SetupStepResult, AGENT_CONFIGS"` succeeds
2. `python -c "from paperforge.setup.checker import SetupChecker"` succeeds  
3. `python -c "from paperforge.setup.config_writer import ConfigWriter"` succeeds
4. SetupStepResult round-trip: create -> to_dict -> from_dict -> equal
5. ConfigWriter tempfile + os.replace: verify atomic by checking file exists and contains valid JSON
6. Backward-compat: `python -c "from paperforge.setup_wizard import CheckResult, EnvChecker"` succeeds
</verification>

<success_criteria>
1. SetupStepResult dataclass defined with step/ok/message/error fields and ok/fail factory methods
2. AGENT_CONFIGS dict exported from paperforge.setup with all 9 platforms
3. SetupChecker validates Python, dependencies, vault, Zotero, BBT, JSON — all returning SetupStepResult
4. ConfigWriter writes paperforge.json atomically via tempfile + os.replace — verified by inspecting the write path
5. Backward-compat imports from setup_wizard.py pass (CheckResult, EnvChecker)
6. All 3 new files pass `python -c "from paperforge.setup.{module} import {class}"` without ImportError
</success_criteria>

<output>
After completion, create `.planning/phases/060-setup-modularization/060-01-SUMMARY.md`
</output>
