---
phase: 51-runtime-selection-setup-gate
plan: 001
type: execute
wave: 1
depends_on: []
files_modified:
  - paperforge/plugin/main.js
autonomous: true
requirements:
  - RUNTIME-01
  - RUNTIME-02
  - RUNTIME-03
  - RUNTIME-06

must_haves:
  truths:
    - "Settings page shows the resolved Python interpreter path with a source label ('auto-detected' / 'manual override')"
    - "User can type a custom Python interpreter path and validate it with a single click"
    - "All subprocess calls (_fetchVersion, _autoUpdate, _runAction, _stepInstall, _preCheck, command palette) use the same resolved interpreter, not a bare 'python' string"
    - "zotero_data_dir placeholder in the wizard says 'Required' not 'Optional'"
    - "Wizard blocks install if zotero_data_dir is empty, does not exist, is not a directory, or lacks a storage/ subdirectory"
    - "Saved python_path override is re-validated on plugin reload; stale override shows a warning state"
  artifacts:
    - path: "paperforge/plugin/main.js"
      provides: "All runtime selection, settings, validation, and interpreter usage logic"
      min_lines: 2527
  key_links:
    - from: "resolvePythonExecutable()"
      to: "DEFAULT_SETTINGS.python_path"
      via: "settings override check"
      pattern: "settings\\.python_path|python_path"
    - from: "resolvePythonExecutable()"
      to: "spawn() / execFile() / exec() calls"
      via: "all subprocess callers use the resolved exe"
      pattern: "resolvePythonExecutable"
    - from: "_validateStep3() / _validate()"
      to: "zotero_data_dir"
      via: "exists + isDirectory + has storage/ subdirectory"
      pattern: "storage"
    - from: "PaperForgeSettingTab display"
      to: "resolvePythonExecutable()"
      via: "read-only Python path row"
      pattern: "Python|python_path"
    - from: "Plugin onload"
      to: "python_path validation"
      via: "re-validation of saved override showing warning state"
      pattern: "onload|python_path"
---

<objective>
Make PaperForge's Python interpreter path visible, user-configurable, consistently used across all subprocess calls, and always validated. At the same time, upgrade the setup wizard's zotero_data_dir from optional to required with proper validation.

Purpose: Deliver RUNTIME-01, RUNTIME-02, RUNTIME-03, RUNTIME-06 in a single pass. Users can see which Python the plugin will use, override it when needed, and never hit a bare `python` call that uses the wrong interpreter. The setup flow can no longer complete without a valid Zotero data directory.
Output: Updated `paperforge/plugin/main.js` with refactored Python resolver, new settings UI row, consistent interpreter across all call sites, and required+validated zotero_data_dir.
</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>
<interfaces>
<!-- Key types and contracts the executor needs. Extracted from existing codebase. -->

From paperforge/plugin/main.js:

```javascript
// Current signature (line 223):
function resolvePythonExecutable(vaultPath) { ... }
// Returns: string (path to python exe, or 'python' fallback)

// DEFAULT_SETTINGS (line 155):
const DEFAULT_SETTINGS = {
    vault_path: '',
    setup_complete: false,
    auto_update: true,
    agent_platform: 'opencode',
    language: '',
    paddleocr_api_key: '',
    zotero_data_dir: '',
};

// Settings persistence — loadSettings (line 2506):
async loadSettings() {
    this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
    // ... path fields from paperforge.json
}

// saveSettings (line 2517):
async saveSettings() {
    const dataToSave = {};
    for (const key of Object.keys(DEFAULT_SETTINGS)) {
        if (key in this.settings) dataToSave[key] = this.settings[key];
    }
    await this.saveData(dataToSave);
}

// Call sites requiring the resolved interpreter:
// 1. _fetchVersion (line 335):     execFile(pythonExe, ['-c', 'import paperforge;...'], ...)
// 2. _runAction (line 1301):       spawn(pythonExe, ['-m', 'paperforge', a.cmd, ...], ...)
// 3. _preCheck (line 1665):        exec('python --version', ...)
// 4. _stepInstall / runPython (line 2120): spawn('python', args, ...)
// 5. _stepComplete (line 2262):    execFile(pythonExe, ['-c', 'import paperforge;...'], ...)
// 6. _autoUpdate (line 2370):      execFile(pythonExe, ...) and spawn(pythonExe, ...)
// 7. Command palette (line 2319):  exec(`python -m paperforge ${a.cmd}`, ...)

// Current detection order (line 223-235):
//   .paperforge-test-venv/Scripts/python.exe
//   .venv/Scripts/python.exe
//   venv/Scripts/python.exe
//   -> 'python' (fallback)

// Current zotero validation in _validateStep3 (line 1826):
//   if (!s.zotero_data_dir || !fs.existsSync(s.zotero_data_dir)) { ... }
//   Only checks existence. Does NOT check directory + storage/ subdirectory.

// Current _validate() for install (line 2199):
//   Checks vault_path, resources_dir, literature_dir, control_dir, base_dir, paddleocr_api_key
//   Does NOT check zotero_data_dir

// i18n strings to update (field_zotero_placeholder):
//   en: 'Optional. Helps auto-locate PDF attachments in Zotero storage'
//   zh: '可选。填写后可帮助自动定位 Zotero 存储中的 PDF 附件'
</interfaces>
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Refactor resolvePythonExecutable + python_path settings + reload re-validation</name>
  <files>
    paperforge/plugin/main.js
  </files>
  <action>
    Make three coordinated changes to `paperforge/plugin/main.js`:

    === A. Refactor resolvePythonExecutable() (currently line 223-235) ===

    Change signature to accept `settings` (the plugin settings object) instead of just `vaultPath`:
    `function resolvePythonExecutable(vaultPath, settings) { ... }`

    New behavior:
    1. If `settings.python_path` is non-empty and `fs.existsSync(settings.python_path)`, return `{ path: settings.python_path, source: 'manual' }` immediately — manual override is absolute source of truth.
    2. Otherwise, check venv candidates (same order as now):
       - `.paperforge-test-venv/Scripts/python.exe`
       - `.venv/Scripts/python.exe`
       - `venv/Scripts/python.exe`
    3. Then try system candidates:
       - `py -3` (Windows Python launcher — use `'py'` with args `['-3']` where needed)
       - `python`
       - `python3`
    4. For each system candidate, use `execFile` to run `--version` — pick the first one that succeeds. Store the path + source label.
    5. Return `{ path: <string>, source: 'auto-detected' }`.
       If nothing works, return `{ path: 'python', source: 'auto-detected' }` (last-resort fallback).

    Per D-01: detection order is: manual override → .paperforge-test-venv → .venv → venv → py -3 → python → python3

    === B. Add python_path to DEFAULT_SETTINGS (line 155) ===

    Add `python_path: ''` to the DEFAULT_SETTINGS object. This stores manual override path.

    === C. Re-validate saved python_path on plugin load ===

    In `loadSettings()` (line 2506), after merging settings, add:
    - If `this.settings.python_path` is non-empty:
      - Check `fs.existsSync(this.settings.python_path)`
      - If stale (doesn't exist), log a console.warn and do NOT clear it — user may fix the path. The settings UI will show warning state.
      - Store a transient flag `this.settings._python_path_stale` on the settings object (NOT persisted to DEFAULT_SETTINGS, excluded from saveData by the key filter in saveSettings).

    Update `saveSettings()` (line 2517): the current loop `for (const key of Object.keys(DEFAULT_SETTINGS))` already filters persisted keys. `_python_path_stale` is NOT in DEFAULT_SETTINGS so it won't be persisted — correct.
  </action>
  <verify>
    <automated>
      grep -n "function resolvePythonExecutable" paperforge/plugin/main.js | head -1 && ^
      grep -n "python_path" paperforge/plugin/main.js | head -5 && ^
      grep -n "source.*manual\|source.*auto" paperforge/plugin/main.js | head -3
    </automated>
  </verify>
  <done>
    - resolvePythonExecutable() accepts (vaultPath, settings), returns {path, source}
    - python_path: '' in DEFAULT_SETTINGS
    - Manual override (settings.python_path non-empty + exists) bypasses auto-detection
    - Detection order includes py -3, python3 per D-01
    - Stale python_path triggers console.warn but doesn't clear the setting
    - _python_path_stale transient flag set on stale override
  </done>
</task>

<task type="auto">
  <name>Task 2: Add Python interpreter row to settings page UI</name>
  <files>
    paperforge/plugin/main.js
  </files>
  <action>
    In `PaperForgeSettingTab.display()` (starting ~line 1545), add a new Python interpreter section between the "Setup Status" bar and the "Preparation Guide" section.

    The section has three sub-elements:

    === 2a — Read-only current interpreter row ===
    Use the Obsidian `new Setting(containerEl)` API (same pattern as the Install button at line 1605).

    Create a read-only row showing:
    - Name: `"Python 解释器"` / `"Python Interpreter"` — use `t()` with a new key `field_python_interp` (add to both LANG.en and LANG.zh)
    - Desc: the resolved Python path + source label, e.g.:
      - `"/usr/bin/python3 (auto-detected)"`
      - `"/custom/path/python.exe (manual)"`
    - The resolved path comes from calling `resolvePythonExecutable(vaultPath, this.plugin.settings)`.
    - If `_python_path_stale` is true on settings, prefix the description with `"[!!] "` and add a note like "Path no longer exists — update or clear the override below."

    === 2b — Manual override text input ===
    Add a second Setting row with:
    - Name: `"自定义路径"` / `"Custom Path"` — use `t()` key `field_python_custom`
    - Desc: optional, explaining this overrides auto-detection
    - Add a text input via `.addText()`, pre-filled with `settings.python_path`
    - On `.onChange()`: save to `settings.python_path`, call `_debouncedSave()`, and immediately re-render the read-only row (2a) to reflect the change
    - If user clears the text input (sets empty string), auto-detection takes over again

    === 2c — Validate button ===
    Add a `.addButton()` to the same row (2b) labeled `"验证"` / `"Validate"` — use `t()` key `btn_validate`.
    On click:
    1. Get the value of the text input
    2. If empty, show Notice saying "请输入路径" / "Enter a path first"
    3. Check `fs.existsSync(path)` — fail: "路径不存在" / "Path does not exist"
    4. Try `fs.accessSync(path, fs.constants.X_OK)` — fail: "不可执行" / "Not executable"
    5. Use `execFile(path, ['--version'], ...)` — fail: "无法运行" / "Cannot run"
    6. Parse the version output — must be ≥ 3.10 — fail: "Python 版本过低，需要 3.10+" / "Python version too low, need 3.10+"
    7. If version passes, run `execFile(path, ['-m', 'pip', '--version'], ...)` — if fails, show warning "✓ Python 3.x 有效，但未检测到 pip" / "Valid Python 3.x, but pip not found" (warn only)
    8. If all pass: show green "✓ Python 3.x.y 有效" / "Valid Python 3.x.y"

    Show validation results in desc area using `.setDesc()` with colored text (use HTML via `.setDesc()` with `<span style="color:var(--text-accent)">` for success, `<span style="color:var(--text-error)">` for failure).

    === Add new i18n keys ===
    Add to both LANG.en and LANG.zh:

    ```javascript
    // In the Object.assign block for LANG.en (~line 24):
    field_python_interp: 'Python Interpreter',
    field_python_custom: 'Custom Path',
    btn_validate: 'Validate',

    // In the Object.assign block for LANG.zh (~line 87):
    field_python_interp: 'Python 解释器',
    field_python_custom: '自定义路径',
    btn_validate: '验证',
    ```

    NOTE: Avoid touching the `PreCheck` section or `_preCheck()` method in this task — it already exists and will have its `exec('python --version', ...)` call replaced in Task 3.
  </action>
  <verify>
    <automated>
      grep -n "field_python_interp\|field_python_custom\|btn_validate" paperforge/plugin/main.js | wc -l
    </automated>
  </verify>
  <done>
    - Settings page shows Python interpreter row with resolved path + source label
    - Custom path text input wired to settings.python_path with debounced save
    - Validate button implements full check chain: exists → executable → version ≥ 3.10 → pip warn
    - Re-renders read-only row on input change to reflect updated path
    - Stale override shows [!!] warning in read-only row
    - Three new i18n keys added to both en and zh
  </done>
</task>

<task type="auto">
  <name>Task 3: Consistent interpreter usage + zotero_data_dir required + validation</name>
  <files>
    paperforge/plugin/main.js
  </files>
  <action>
    Two independent sets of changes. Both are in main.js.

    === Part A: Replace bare 'python' with resolved interpreter (RUNTIME-03) ===

    Update ALL call sites to use `resolvePythonExecutable(vaultPath, this.plugin.settings|settings)` and destructure `.path`:

    1. **`_fetchVersion()`** (line 335-344):
       - Change `const pythonExe = resolvePythonExecutable(vp);` → `const { path: pythonExe } = resolvePythonExecutable(vp, plugin?.settings);`
       - The `plugin` variable: `_fetchVersion` is on `PaperForgeStatusView`, which has `this.app.plugins.plugins['paperforge']` (as used at line 357). So change to `const plugin = this.app.plugins.plugins['paperforge']; const { path: pythonExe } = resolvePythonExecutable(vp, plugin?.settings);`

    2. **`_runAction()`** (line 1351-1354):
       - Change `const pythonExe = resolvePythonExecutable(vp);` → `const { path: pythonExe } = resolvePythonExecutable(vp, this.app.plugins.plugins['paperforge']?.settings);`
       - Keep the rest the same — already uses `pythonExe` for spawn.

    3. **`_preCheck()`** (line 1665-1666):
       - Replace `exec('python --version', ...)` with:
         ```javascript
         const plugin = this.plugin;
         const vaultPath = this.app.vault.adapter.basePath;
         const { path: pythonExe } = resolvePythonExecutable(vaultPath, plugin?.settings);
         execFile(pythonExe, ['--version'], { timeout: 8000 }, (pyErr, pyOut) => {
         ```
       - Update the error message reference accordingly (pyErr still works with execFile).

    4. **`_stepInstall()` / `runPython()`** (line 2120-2126):
       - Inside `_runInstall`, change the `runPython` inner function:
         ```javascript
         const runPython = (args, options = {}) => new Promise((resolve, reject) => {
             const { path: pythonExe } = resolvePythonExecutable(s.vault_path.trim(), this.plugin.settings);
             const child = spawn(pythonExe, args, {
         ```
       - This replaces `spawn('python', args, ...)` with the resolved interpreter, per D-03.

    5. **`_stepComplete()`** (line 2261-2263):
       - Change `const pythonExe = resolvePythonExecutable(vp);` → `const { path: pythonExe } = resolvePythonExecutable(vault, this.plugin.settings);`
       - Already uses `execFile(pythonExe, ...)`.

    6. **`_autoUpdate()`** (line 2370-2397):
       - Has an outer `resolvePythonExecutable(vp)` at line 2372.
       - Change to: `const { path: pythonExe } = resolvePythonExecutable(vp, this.settings);`
       - Both `execFile` and `spawn` inside already use `pythonExe`. Good.

    7. **Command palette action handlers** (line 2317-2319):
       - Replace `exec('python -m paperforge ${a.cmd}', ...)` with:
         ```javascript
         const { path: pythonExe } = resolvePythonExecutable(vp, this.settings);
         exec(`${pythonExe} -m paperforge ${a.cmd}`, { cwd: vp, timeout: 300000 }, ...)
         ```
       - NOTE: If pythonExe contains spaces (e.g., `C:\Program Files\...`), this will break. Use `execFile` instead:
         ```javascript
         const { path: pythonExe } = resolvePythonExecutable(vp, this.settings);
         execFile(pythonExe, ['-m', 'paperforge', a.cmd], { cwd: vp, timeout: 300000 }, ...)
         ```
       - This avoids the shell-quoting problem entirely.

    === Part B: Make zotero_data_dir required (RUNTIME-06) ===

    === B1 — Update i18n strings ===
    Change `field_zotero_placeholder` in BOTH LANG.en and LANG.zh from "Optional" to "Required" language:
    - en: `'Required. Path to Zotero data directory for PDF attachment resolution.'`
    - zh: `'必填。Zotero 数据目录路径，用于解析 PDF 附件位置。'`

    === B2 — Update zotero input placeholder in wizard ===
    In `_stepKeys()` (line 1971-1973), change the hardcoded placeholder from its current value to `t('field_zotero_placeholder')` — use the i18n key instead of a hardcoded string, so it picks up the new "Required" language.

    === B3 — Enhance _validateStep3() zotero validation ===
    In `_validateStep3()` (line 1815-1831), replace the simple existence check:
    ```javascript
    _validateStep3() {
        const s = this.plugin.settings;
        const fs = require('fs');

        // 1. Check API key validated
        if (!this._apiKeyValidated) {
            new Notice('请先验证 PaddleOCR API 密钥');
            return false;
        }

        // 2. Check Zotero data directory: required + exists + is directory + has storage/
        if (!s.zotero_data_dir || !s.zotero_data_dir.trim()) {
            new Notice('Zotero 数据目录为必填项，请填写路径');
            return false;
        }
        const zotPath = s.zotero_data_dir.trim();
        if (!fs.existsSync(zotPath)) {
            new Notice('Zotero 数据目录路径不存在');
            return false;
        }
        if (!fs.statSync(zotPath).isDirectory()) {
            new Notice('Zotero 数据目录路径不是一个目录');
            return false;
        }
        const storagePath = path.join(zotPath, 'storage');
        if (!fs.existsSync(storagePath) || !fs.statSync(storagePath).isDirectory()) {
            new Notice('Zotero 数据目录中未找到 storage/ 子目录');
            return false;
        }

        return true;
    }
    ```
    Per D-05: validation blocks install with specific error messages for each failure mode.

    === B4 — Update _validate() for install ===
    In `_validate()` (line 2199-2208), add zotero_data_dir check:
    ```javascript
    if (!s.zotero_data_dir || !s.zotero_data_dir.trim()) errors.push(t('validate_zotero'));
    ```
    Add the i18n key:
    ```javascript
    // LANG.en:
    validate_zotero: 'Zotero data directory is required',
    // LANG.zh:
    validate_zotero: 'Zotero 数据目录为必填项',
    ```

    === B5 — zotero_data_dir always passed to setup ===
    In `_runInstall()` (line 2156-2158), remove the conditional:
    ```javascript
    // OLD:
    if (s.zotero_data_dir && s.zotero_data_dir.trim()) {
        setupArgs.push('--zotero-data', s.zotero_data_dir.trim());
    }
    // NEW (unconditional):
    setupArgs.push('--zotero-data', s.zotero_data_dir.trim());
    ```
    Since zotero_data_dir is now validated before reaching _runInstall(), it's safe to always pass it.
  </action>
  <verify>
    <automated>
      # Verify no bare 'python' spawn/exec calls remain (1 false-positive for "resolvePythonExecutable" itself)
      grep -n "spawn.*'python'" paperforge/plugin/main.js; echo "---"; ^
      grep -n "exec.*'python " paperforge/plugin/main.js; echo "---"; ^
      grep -n "resolvePythonExecutable" paperforge/plugin/main.js | wc -l
    </automated>
  </verify>
  <done>
    - All 7 subprocess call sites use resolvePythonExecutable() for the interpreter
    - No bare `spawn('python', ...)` or `exec('python ...')` calls remain
    - Spaces in path handled via execFile instead of shell-string exec
    - zotero_data_dir placeholder i18n changed from "Optional" to "Required"
    - Wizard placeholder uses t() call
    - _validateStep3 checks: non-empty → exists → isDirectory → has storage/ subdirectory
    - _validate() rejects missing zotero_data_dir
    - zotero_data_dir always passed to --zotero-data flag (no conditional)
    - validate_zotero i18n key added to both en and zh
  </done>
</task>

</tasks>

<verification>

1. Run `node -e "require('./paperforge/plugin/main.js')"` — should not throw parse errors (module.exports class)
2. Search for bare `spawn('python'` — should have 0 results (the only spawn is `spawn(pythonExe, ...)`)
3. Search for `exec('python '` or `exec('python --` — should have 0 results (all replaced)
4. Search for `resolvePythonExecutable` — should appear at all call sites and definition
5. Search for `field_zotero_placeholder` — values should say "Required" / "必填"
6. Search for `validate_zotero` — should be in both LANG.en and LANG.zh
7. Walk through all 7 call sites to confirm each uses destructured `.path` from resolvePythonExecutable

</verification>

<success_criteria>

- RUNTIME-01: Settings shows resolved Python path + source label
- RUNTIME-02: Manual override text input + Validate button with full check chain
- RUNTIME-03: All subprocess calls use resolvePythonExecutable() — zero bare `'python'` strings
- RUNTIME-06: zotero_data_dir required, validated (exists + dir + storage/ subdir), blocks install on failure
- Saved python_path is re-validated on plugin reload; stale path shows warning but isn't silently cleared

</success_criteria>

<output>
After completion, create `.planning/phases/51-runtime-selection-setup-gate/51-001-SUMMARY.md`
</output>
