---
phase: 57-contract-layer
plan: 04
type: execute
wave: 2
depends_on:
  - 57-01
files_modified:
  - paperforge/cli.py
  - paperforge/commands/__init__.py
  - paperforge/commands/dashboard.py
  - paperforge/worker/asset_index.py
  - paperforge/plugin/main.js
  - tests/cli/test_json_contracts.py
autonomous: true
requirements:
  - CTRT-07
  - CTRT-08

must_haves:
  truths:
    - "paperforge dashboard --json returns PFResult with stats (papers, pdf_health, ocr_health, domain_counts)"
    - "paperforge dashboard --json returns PFResult with permissions (can_sync, can_ocr, can_copy_context)"
    - "Plugin reads dashboard data via paperforge dashboard --json CLI contract"
    - "Plugin fallback to direct formal-library.json reading still works"
  artifacts:
    - path: "paperforge/commands/dashboard.py"
      provides: "Dashboard CLI command with --json output"
      exports: ["run"]
    - path: "paperforge/commands/__init__.py"
      provides: "dashboard entry in command registry"
    - path: "paperforge/plugin/main.js"
      provides: "Updated plugin _fetchStats to read dashboard --json"
  key_links:
    - from: "paperforge/commands/dashboard.py"
      to: "paperforge/core/result.py"
      via: "PFResult wrapping"
    - from: "paperforge/commands/dashboard.py"
      to: "paperforge/worker/asset_index.py"
      via: "read_index and summarize_index"
    - from: "paperforge/plugin/main.js"
      to: "paperforge/commands/dashboard.py"
      via: "execFile python -m paperforge dashboard --json"
---

<objective>
Implement `paperforge dashboard --json` command with stats (papers, pdf_health, ocr_health, domain_counts) and permissions (can_sync, can_ocr, can_copy_context). Update plugin to read dashboard data via CLI contract with fallback to direct index reading.

Purpose: Expose machine-readable dashboard data in PFResult format for the Obsidian plugin and other consumers.
Output: New commands/dashboard.py module, CLI registration, plugin _fetchStats update, contract tests
</objective>

<execution_context>
@C:/Users/Lin/.opencode/get-shit-done/workflows/execute-plan.md
@C:/Users/Lin/.opencode/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/057-contract-layer/057-CONTEXT.md

<interfaces>
Per CONTEXT.md Dashboard Contract:
- Stats: papers (total count), pdf_health (healthy/broken/missing), ocr_health (pending/done/failed), domain_counts (dict of domain -> count)
- Permissions: can_sync (BBT exports exist), can_ocr (PaddleOCR configured), can_copy_context (system_dir writable)
- Version: from PFResult envelope (not duplicated in data)

Existing plugin _fetchStats (main.js:338-430):
- Reads formal-library.json directly
- Falls back to `python -m paperforge status --json`
- Computes stats from index items (lifecycle, health, OCR counts)
- Needs to be updated to try dashboard --json first, then fallback

Existing paperforge/worker/asset_index.py provides:
- read_index(vault) -> dict with {"items": [...], "paperforge_version": "..."}
- summarize_index(vault) -> dict with paper_count, lifecycle_level_counts, health_aggregate, maturity_distribution

Existing config checks (from paperforge/worker/status.py for permissions):
- BBT exports: paths["exports"].glob("*.json")
- OCR token: os.environ.get("PADDLEOCR_API_TOKEN")
- System dir writable: os.access(paths["system"], os.W_OK)
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Create dashboard command with --json PFResult output</name>
  <files>
    paperforge/commands/dashboard.py
    paperforge/commands/__init__.py
    paperforge/cli.py
  </files>
  <action>
    Step A: Create paperforge/commands/dashboard.py:

    ```python
    """Dashboard command -- expose stats and permissions for the plugin UI."""
    from __future__ import annotations

    import argparse
    import os
    import logging
    from pathlib import Path

    logger = logging.getLogger(__name__)


    def _get_index_summary(vault: Path) -> dict | None:
        """Read canonical index summary. Returns None if unavailable."""
        try:
            from paperforge.worker.asset_index import summarize_index, read_index
            summary = summarize_index(vault)
            index_data = read_index(vault)
            domain_counts: dict[str, int] = {}
            if index_data and "items" in index_data:
                for item in index_data["items"]:
                    domain = item.get("domain", "unknown")
                    domain_counts[domain] = domain_counts.get(domain, 0) + 1
            if summary:
                summary["domain_counts"] = domain_counts
            return summary
        except Exception:
            return None


    def _check_permissions(vault: Path, cfg: dict) -> dict:
        """Check system permissions for dashboard."""
        from paperforge.config import paperforge_paths
        paths = paperforge_paths(vault, cfg)
        exports_dir = paths.get("exports", vault / cfg["system_dir"] / "PaperForge" / "exports")
        system_dir = paths.get("system", vault / cfg["system_dir"])

        bbt_exports = sorted(exports_dir.glob("*.json")) if exports_dir.exists() else []
        can_sync = len(bbt_exports) > 0

        ocr_token = os.environ.get("PADDLEOCR_API_TOKEN") or os.environ.get("PADDLEOCR_API_KEY") or ""
        can_ocr = bool(ocr_token.strip())

        can_copy_context = os.access(str(system_dir), os.W_OK) if system_dir.exists() else False

        return {
            "can_sync": can_sync,
            "can_ocr": can_ocr,
            "can_copy_context": can_copy_context,
        }


    def run(args: argparse.Namespace) -> int:
        """Run dashboard command."""
        vault = getattr(args, "vault_path", None)
        if vault is None:
            from paperforge.config import resolve_vault
            vault = resolve_vault(cli_vault=getattr(args, "vault", None))

        cfg = getattr(args, "cfg", None)
        if cfg is None:
            from paperforge.config import load_vault_config
            cfg = load_vault_config(vault)

        from paperforge.config import paperforge_paths
        paths = paperforge_paths(vault, cfg)

        json_output = getattr(args, "json_output", False)

        # Build dashboard data
        summary = _get_index_summary(vault)
        permissions = _check_permissions(vault, cfg)

        # Count papers from index or fallback to literature dir
        papers = summary.get("paper_count", 0) if summary else 0

        domain_counts = summary.get("domain_counts", {}) if summary else {}

        # PDF health from health_aggregate
        health = summary.get("health_aggregate", {}) if summary else {}
        pdf_health = health.get("pdf_health", {"healthy": 0, "unhealthy": 0})
        ocr_health_agg = health.get("ocr_health", {"healthy": 0, "unhealthy": 0})

        # OCR breakdown from direct scan
        ocr_total = 0
        ocr_pending = 0
        ocr_done = 0
        ocr_failed = 0
        if vault and paths.get("literature"):
            import re
            do_ocr_keys: set[str] = set()
            for note_path in paths["literature"].rglob("*.md"):
                if note_path.name in ("fulltext.md", "deep-reading.md", "discussion.md"):
                    continue
                try:
                    text = note_path.read_text(encoding="utf-8")
                    if re.search(r"^do_ocr:\s*true\s*$", text, re.MULTILINE):
                        m = re.search(r"^zotero_key:\s*(\S+)", text, re.MULTILINE)
                        if m:
                            do_ocr_keys.add(m.group(1))
                except Exception:
                    continue
            for key in do_ocr_keys:
                ocr_total += 1
                meta_path = paths.get("ocr", vault / cfg["system_dir"] / "PaperForge" / "ocr") / key / "meta.json"
                if not meta_path.exists():
                    ocr_pending += 1
                    continue
                try:
                    from paperforge.worker._utils import read_json
                    meta = read_json(meta_path)
                    status = str(meta.get("ocr_status", "")).strip().lower()
                    if status == "done":
                        ocr_done += 1
                    else:
                        ocr_failed += 1
                except Exception:
                    ocr_failed += 1

        data = {
            "papers": papers,
            "pdf_health": {
                "healthy": pdf_health.get("healthy", 0),
                "broken": pdf_health.get("unhealthy", 0),
                "missing": 0,
            },
            "ocr_health": {
                "total": ocr_total,
                "pending": ocr_pending,
                "done": ocr_done,
                "failed": ocr_failed,
            },
            "domain_counts": domain_counts,
            "permissions": permissions,
        }

        if json_output:
            from paperforge.core.result import PFResult
            from paperforge import __version__
            result = PFResult(ok=True, command="dashboard", version=__version__, data=data)
            print(result.to_json())
        else:
            print(f"Papers: {papers}")
            print(f"PDF Health: {data['pdf_health']['healthy']} healthy, {data['pdf_health']['broken']} broken, {data['pdf_health']['missing']} missing")
            print(f"OCR Health: {ocr_done}/{ocr_total} done (pending: {ocr_pending}, failed: {ocr_failed})")
            print(f"Domains: {len(domain_counts)}")
            print(f"Permissions: sync={'Yes' if permissions['can_sync'] else 'No'}, ocr={'Yes' if permissions['can_ocr'] else 'No'}, copy_context={'Yes' if permissions['can_copy_context'] else 'No'}")

        return 0
    ```

    Step B: Register in paperforge/commands/__init__.py:
    ```python
    _COMMAND_REGISTRY: dict[str, str] = {
        ...
        "dashboard": "paperforge.commands.dashboard",
    }
    ```

    Step C: Add subparser in paperforge/cli.py:
    ```python
    p_dashboard = sub.add_parser("dashboard", help="Show dashboard with stats and permissions")
    p_dashboard.add_argument("--json", action="store_true", dest="json_output", help="Output JSON")
    ```

    Step D: Add dispatch in cli.py main():
    ```python
    if args.command == "dashboard":
        from paperforge.commands import dashboard
        return dashboard.run(args)
    ```
    Place this alongside the other command dispatch blocks (after the doctor block, before update).

    Per user decision (CONTEXT.md): Dashboard contract has stats (papers, pdf_health as healthy/broken/missing, ocr_health as pending/done/failed, domain_counts) and permissions (can_sync, can_ocr, can_copy_context). Version from PFResult envelope only.
  </action>
  <verify>
    <automated>python -m paperforge dashboard --json --vault C:\Users\Lin\AppData\Local\Temp\opencode 2>&1 || true</automated>
  </verify>
  <done>dashboard --json produces PFResult with stats (papers, pdf_health, ocr_health, domain_counts) and permissions (can_sync, can_ocr, can_copy_context). Text output also works.</done>
</task>

<task type="auto">
  <name>Task 2: Update plugin to read dashboard via CLI contract</name>
  <files>
    paperforge/plugin/main.js
  </files>
  <action>
    Modify the plugin's _fetchStats method (main.js:338-430) to support the PFResult dashboard contract:

    1. Keep the existing direct index reading path (formal-library.json) as the PRIMARY path.

    2. Add a new hybrid read strategy. After the existing try/catch for index reading fails (fallback path, line 404-428), add a SECOND fallback that calls `paperforge dashboard --json`:
    ```javascript
    // In the catch block (line 404), after the existing status --json fallback:
    // Before the existing status --json fallback, try dashboard --json first
    ```

    Actually, modify the fallback order:
    ```
    Primary: formal-library.json (existing)
    Fallback 1: paperforge dashboard --json (new)  
    Fallback 2: paperforge status --json (existing)
    ```

    The _fetchStats function currently looks like:
    ```
    try:
        read formal-library.json directly -> compute stats -> _renderStats(d)
    catch (err):
        fallback to status --json -> _renderStats(d)
    ```

    Change to:
    ```
    try:
        read formal-library.json directly -> compute stats -> _renderStats(d)
    catch (err):
        try:
            execFile python -m paperforge dashboard --json
            d = JSON.parse(stdout)
            // Check PFResult shape: use d.data if PFResult, or d directly if old format
            const dashboardData = d.data || d;
            this._cachedStats = {
                version: d.version || d.version,
                total_papers: dashboardData.papers || 0,
                formal_notes: 0,
                exports: 0,
                bases: 0,
                ocr: dashboardData.ocr_health || { total: 0, pending: 0, processing: 0, done: 0, failed: 0 },
                path_errors: 0,
                lifecycle_level_counts: {},
                health_aggregate: {
                    pdf_health: { healthy: dashboardData.pdf_health?.healthy || 0, unhealthy: (dashboardData.pdf_health?.broken || 0) + (dashboardData.pdf_health?.missing || 0) },
                    ocr_health: { healthy: dashboardData.ocr_health?.done || 0, unhealthy: (dashboardData.ocr_health?.pending || 0) + (dashboardData.ocr_health?.failed || 0) },
                    note_health: { healthy: 0, unhealthy: 0 },
                    asset_health: { healthy: 0, unhealthy: 0 },
                },
            };
            this._renderStats(this._cachedStats);
            this._renderOcr(this._cachedStats);
        catch (err2):
            // Keep existing status --json fallback
            execFile python -m paperforge status --json ...
    ```

    The key conversion map:
    - dashboard.papers -> total_papers
    - dashboard.pdf_health.healthy + broken/missing -> health_aggregate.pdf_health
    - dashboard.ocr_health -> ocr stats (for _renderOcr)
    - dashboard.permissions -> store for potential UI use (not rendered yet, but available)

    3. Ensure the _renderStats function (line 500) can handle both formats:
       - PFResult shaped: {ok, command, version, data: {papers, pdf_health, ...}} -- extract data sub-object
       - Old status format: {total_papers, version, ocr, ...} -- use directly
    The current _renderStats reads d.total_papers, d.version, d.ocr, etc. After the dashboard data is mapped to _cachedStats with the old shape keys (total_papers, ocr, etc.), _renderStats works unchanged.

    Per user decision: Plugin keeps fallback to direct index reading during PFResult transition -- removed after 2 stable release cycles.
  </action>
  <verify>
    <automated>node -e "console.log('Plugin logic verification: see test file')"</automated>
  </verify>
  <done>Plugin _fetchStats falls back to dashboard --json when formal-library.json is unavailable. Old format reading still works. Status --json remains as final fallback.</done>
</task>

<task type="auto">
  <name>Task 3: Add CLI contract tests for dashboard --json</name>
  <files>
    tests/cli/test_json_contracts.py
  </files>
  <action>
    In tests/cli/test_json_contracts.py, add TestDashboardJson class:

    - REQUIRED_KEYS = {"ok", "command", "version", "data", "error"}
    - DATA_KEYS = {"papers", "pdf_health", "ocr_health", "domain_counts", "permissions"}
    - PERM_KEYS = {"can_sync", "can_ocr", "can_copy_context"}

    Tests:
    1. test_dashboard_json_pfresult_shape: invokes dashboard --json, verifies PFResult envelope
    2. test_dashboard_json_data_keys: verifies data contains all required stat keys
    3. test_dashboard_json_permissions_shape: verifies permissions has can_sync/can_ocr/can_copy_context
    4. test_dashboard_json_no_vault: nonexistent vault exits non-zero

    Reuse existing assert_json_shape and assert_valid_json helpers.
  </action>
  <verify>
    <automated>python -m pytest tests/cli/test_json_contracts.py -v --tb=short -k "dashboard" 2>&1 || true</automated>
  </verify>
  <done>Dashboard PFResult contract tests pass. All 4 dashboard test methods verify shape and content.</done>
</task>

</tasks>

<verification>
- python -m paperforge dashboard --json produces valid PFResult JSON
- python -m pytest tests/cli/test_json_contracts.py -k "dashboard" passes
- Plugin can parse dashboard --json output via execFile
- Text output path works: python -m paperforge dashboard
</verification>

<success_criteria>
- [ ] dashboard command registered in cli.py and commands/__init__.py
- [ ] dashboard --json returns PFResult with stats (papers, pdf_health, ocr_health, domain_counts)
- [ ] dashboard --json returns PFResult with permissions (can_sync, can_ocr, can_copy_context)
- [ ] Plugin fallback reads dashboard --json when formal-library.json is unavailable
- [ ] Plugin fallback to status --json remains as final option
- [ ] Contract tests pass for dashboard
</success_criteria>

<output>
After completion, create .planning/phases/057-contract-layer/057-04-SUMMARY.md
</output>
