---
phase: 57-contract-layer
plan: 03
type: execute
wave: 2
depends_on:
  - 57-01
files_modified:
  - paperforge/commands/sync.py
  - paperforge/commands/ocr.py
  - paperforge/ocr_diagnostics.py
  - paperforge/worker/sync.py
  - tests/cli/test_json_contracts.py
autonomous: true
requirements:
  - CTRT-05
  - CTRT-06

must_haves:
  truths:
    - "paperforge sync --json returns PFResult with sync result data"
    - "paperforge ocr --diagnose --json returns PFResult with diagnostic data"
    - "Both outputs have valid {ok, command, version, data, error} PFResult shape"
  artifacts:
    - path: "paperforge/commands/sync.py"
      provides: "--json flag on sync command with PFResult output"
      contains: "PFResult"
    - path: "paperforge/commands/ocr.py"
      provides: "--json flag on ocr --diagnose with PFResult output"
      contains: "json_output"
    - path: "paperforge/ocr_diagnostics.py"
      provides: "Optional enhanced ocr_doctor with json-friendly output"
    - path: "tests/cli/test_json_contracts.py"
      provides: "PFResult shape tests for sync --json and ocr --diagnose --json"
  key_links:
    - from: "paperforge/commands/sync.py"
      to: "paperforge/core/result.py"
      via: "import PFResult"
    - from: "paperforge/commands/ocr.py"
      to: "paperforge/core/result.py"
      via: "import PFResult"
---

<objective>
Add --json flag to sync and ocr --diagnose commands, wrapping output in PFResult envelope (CTRT-05, CTRT-06). Sync reports operation status (selection-sync + index-refresh). OCR diagnose wraps existing diagnostic dict.

Purpose: All core CLI commands produce consistent PFResult JSON output.
Output: Modified sync.py, ocr.py with --json flags; 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>
paperforge/commands/sync.py run(args):
- Calls run_selection_sync(vault, verbose) and run_index_refresh(vault, verbose, rebuild_index)
- Both return int exit codes
- Current output: text logs via print/logger, no structured JSON

paperforge/commands/ocr.py run(args):
- Calls _diagnose(vault, live) for --diagnose
- _diagnose calls ocr_doctor(config, live) which returns dict with {level, passed, error, fix, raw_response}
- Current output: human-readable text via print, no --json flag

paperforge/ocr_diagnostics.py ocr_doctor(config, live):
- Returns dict: {"level": int, "passed": bool, "error": str, "fix": str, "raw_response": str (optional)}
- Already a structured result — ideal for PFResult data payload
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Add --json to sync command with PFResult output</name>
  <files>
    paperforge/commands/sync.py
  </files>
  <action>
    In paperforge/commands/sync.py:

    1. Add `--json` argument at the top of the module (after domain arg):
    ```python
    # The --json flag is passed via args — sync command currently gets its args from cli.py dispatch.
    ```
    Actually, the sync parser is in cli.py. Add to the sync subparser in paperforge/cli.py:
    ```python
    p_sync.add_argument("--json", action="store_true", dest="json_output", help="Output JSON")
    ```

    2. In sync.py `run()` function, after both selection_sync and index_refresh complete, add:
    ```python
    if getattr(args, "json_output", False):
        from paperforge.core.result import PFResult, PFError
        from paperforge.core.errors import ErrorCode
        from paperforge import __version__

        data = {
            "selection_sync": {"exit_code": selection_code} if selection_code is not None else None,
            "index_refresh": {"exit_code": index_code} if index_code is not None else None,
            "vault": str(vault),
        }

        if exit_code == 0:
            result = PFResult(ok=True, command="sync", version=__version__, data=data)
        else:
            result = PFResult(
                ok=False, command="sync", version=__version__, data=data,
                error=PFError(code=ErrorCode.SYNC_FAILED.value, message="Sync completed with errors", details={"selection_code": selection_code, "index_code": index_code}),
            )
        print(result.to_json())
        return exit_code
    ```

    3. Track the individual exit codes: store `selection_code` and `index_code` variables instead of reusing `code`.
    ```python
    selection_code = None
    index_code = None
    if not index_only:
        run_selection_sync = _get_run_selection_sync()
        selection_code = run_selection_sync(vault, verbose=getattr(args, "verbose", False))
        if selection_code != 0:
            exit_code = selection_code
    if not selection_only:
        run_index_refresh = _get_run_index_refresh()
        index_code = run_index_refresh(vault, verbose=getattr(args, "verbose", False), rebuild_index=rebuild_index)
        if index_code != 0 and exit_code == 0:
            exit_code = index_code
    ```

    4. IMPORTANT: The --json block must come AFTER both workers complete but BEFORE the `return exit_code`. The text output path (existing behavior) must remain unchanged.

    Capture worker std output if possible, or just report exit codes. Do NOT change worker return types.
  </action>
  <verify>
    <automated>python -m paperforge sync --json --vault C:\Users\Lin\AppData\Local\Temp\opencode 2>&1 || true</automated>
  </verify>
  <done>sync --json produces PFResult with selection_sync and index_refresh exit codes in data. Text output path unchanged.</done>
</task>

<task type="auto">
  <name>Task 2: Add --json to ocr --diagnose with PFResult output</name>
  <files>
    paperforge/commands/ocr.py
    paperforge/cli.py
  </files>
  <action>
    Two modifications:

    A. In paperforge/cli.py, add --json to the ocr parser:
    ```python
    p_ocr.add_argument("--json", action="store_true", dest="json_output", help="Output JSON")
    ```
    (The --json flag applies to both ocr run and ocr diagnose, but only diagnose uses it in this phase.)

    B. In paperforge/commands/ocr.py, modify _diagnose function:
    1. Add `json_output: bool = False` parameter
    2. Inside _diagnose, after getting the `result` dict from ocr_doctor, add:
    ```python
    if json_output:
        from paperforge.core.result import PFResult, PFError
        from paperforge.core.errors import ErrorCode
        from paperforge import __version__

        passed = result.get("passed", False)
        if passed:
            pf_result = PFResult(
                ok=True, command="ocr-diagnose", version=__version__,
                data={"level": result.get("level"), "passed": True, "message": result.get("message", "All checks passed")},
            )
        else:
            pf_result = PFResult(
                ok=False, command="ocr-diagnose", version=__version__,
                data={"level": result.get("level"), "passed": False},
                error=PFError(
                    code=ErrorCode.OCR_TOKEN_MISSING.value if result.get("level") == 1 else ErrorCode.INTERNAL_ERROR.value,
                    message=result.get("error", "Unknown failure"),
                    details={"fix": result.get("fix", ""), "raw_response": result.get("raw_response", "")},
                ),
            )
        print(pf_result.to_json())
        return 0 if passed else 1
    ```
    3. Keep the existing text output path unchanged (before the `if json_output:` block).

    C. Update the call site in ocr.py `run()`:
    ```python
    diagnose_only = getattr(args, "diagnose", False) or ocr_action == "doctor"
    json_output = getattr(args, "json_output", False)
    if diagnose_only:
        return _diagnose(vault, live=live, json_output=json_output)
    ```
  </action>
  <verify>
    <automated>python -m paperforge ocr --diagnose --json --vault C:\Users\Lin\AppData\Local\Temp\opencode 2>&1 || true</automated>
  </verify>
  <done>ocr --diagnose --json produces PFResult with diagnostic level/passed/message in data. Text path unchanged.</done>
</task>

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

    1. TestSyncJson class:
    - REQUIRED_KEYS = {"ok", "command", "version", "data", "error"}
    - test_sync_json_pfresult_shape: invokes sync --json on empty vault, verifies PFResult shape
    - test_sync_json_data_has_keys: verifies data has selection_sync and index_refresh keys
    - test_sync_json_no_vault: nonexistent vault exits non-zero

    2. TestOcrDiagnoseJson class:
    - REQUIRED_KEYS = {"ok", "command", "version", "data", "error"}
    - test_ocr_diagnose_json_pfresult_shape: invokes ocr --diagnose --json, verifies PFResult shape
    - test_ocr_diagnose_json_data: verifies data has level and passed keys
    - test_ocr_diagnose_json_no_vault: nonexistent vault exits non-zero

    Reuse the existing assert_json_shape and assert_valid_json helpers from test_contract_helpers.py.
    Keep tests compact: each test class with 3 methods.
  </action>
  <verify>
    <automated>python -m pytest tests/cli/test_json_contracts.py -v --tb=short -k "sync or ocr" 2>&1 || true</automated>
  </verify>
  <done>New PFResult contract tests for sync and ocr pass alongside existing tests.</done>
</task>

</tasks>

<verification>
- paperforge sync --json produces valid PFResult JSON
- paperforge ocr --diagnose --json produces valid PFResult JSON
- All existing sync/ocr text output paths unchanged
- All tests pass
</verification>

<success_criteria>
- [ ] sync --json added with PFResult output (data contains selection_sync and index_refresh codes)
- [ ] ocr --diagnose --json added with PFResult output (data contains level/passed/message)
- [ ] Both commands' text output paths preserved
- [ ] Contract tests pass for both commands
</success_criteria>

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