---
phase: 53-plugin-e2e
plan: 001
type: execute
wave: 1
depends_on: ["52-001", "52-002"]
files_modified:
  - paperforge/plugin/src/runtime.js
  - paperforge/plugin/src/errors.js
  - paperforge/plugin/src/commands.js
  - paperforge/plugin/main.js
  - paperforge/plugin/package.json
  - paperforge/plugin/vitest.config.ts
  - tests/plugin/runtime.test.js
  - tests/plugin/errors.test.js
  - tests/plugin/commands.test.js
autonomous: true
requirements: [PLUG-01, PLUG-02, PLUG-03, CI-04]

must_haves:
  truths:
    - "resolvePythonExecutable correctly resolves Python using manual override (settings.python_path), venv candidates (.paperforge-test-venv, .venv, venv), and system candidates (py -3, python, python3)"
    - "getPluginVersion returns the plugin version from manifest.json"
    - "checkRuntimeVersion spawns `python -c 'import paperforge; print(paperforge.__version__)'` and returns match/mismatch/not-installed compared to plugin version"
    - "Error classification covers all 5 patterns: Python missing (no candidate resolves), import failed (packaging not installed), version mismatch (pyVer !== pluginVer), pip install failure (exit code non-zero), timeout (execFile timeout)"
    - "buildRuntimeInstallCommand constructs correct `pip install --upgrade git+...@<version>` command for a given version tag"
    - "parseRuntimeStatus correctly parses stdout/stderr from version check subprocess into a structured result {status, pyVersion, pluginVersion, error}"
    - "CI Node 20 runner job executes `npx vitest run` in paperforge/plugin/ and passes on plugin test changes"
  artifacts:
    - path: "paperforge/plugin/src/runtime.js"
      provides: "Exported resolvePythonExecutable(vaultPath, settings), getPluginVersion(app), checkRuntimeVersion(app, pythonExe)"
      min_lines: 60
    - path: "paperforge/plugin/src/errors.js"
      provides: "Exported classifyError(errorCode), buildRuntimeInstallCommand(version), parseRuntimeStatus(err, stdout, stderr)"
      exports: ["classifyError", "buildRuntimeInstallCommand", "parseRuntimeStatus"]
      min_lines: 50
    - path: "paperforge/plugin/src/commands.js"
      provides: "Exported ACTIONS config, buildCommandArgs(action, key), runSubprocess(pythonExe, args, cwd, timeout)"
      exports: ["ACTIONS", "buildCommandArgs", "runSubprocess"]
      min_lines: 40
    - path: "paperforge/plugin/package.json"
      provides: "Node package config with vitest, obsidian-test-mocks, jsdom as devDependencies"
      contains: '"vitest"'
    - path: "paperforge/plugin/vitest.config.ts"
      provides: "Vitest config with jsdom environment, test directory pointing to tests/plugin/"
      min_lines: 10
    - path: "tests/plugin/runtime.test.js"
      provides: "Vitest tests for resolvePythonExecutable (manual, venv, system paths + fallback), getPluginVersion, checkRuntimeVersion"
      min_lines: 60
    - path: "tests/plugin/errors.test.js"
      provides: "Vitest tests for classifyError (5 patterns), buildRuntimeInstallCommand, parseRuntimeStatus"
      min_lines: 50
    - path: "tests/plugin/commands.test.js"
      provides: "Vitest tests for ACTIONS structure, buildCommandArgs, runSubprocess dispatch"
      min_lines: 40
  key_links:
    - from: "paperforge/plugin/main.js"
      to: "paperforge/plugin/src/runtime.js"
      via: "require('./src/runtime')"
      pattern: "require\\(['\"]\\./src/runtime['\"]\\)"
    - from: "paperforge/plugin/main.js"
      to: "paperforge/plugin/src/errors.js"
      via: "require('./src/errors')"
      pattern: "require\\(['\"]\\./src/errors['\"]\\)"
    - from: "paperforge/plugin/main.js"
      to: "paperforge/plugin/src/commands.js"
      via: "require('./src/commands')"
      pattern: "require\\(['\"]\\./src/commands['\"]\\)"
    - from: "vitest.config.ts"
      to: "tests/plugin/"
      via: "test configuration"
      pattern: "test\\.dir|include.*tests/plugin"
    - from: ".github/workflows/ci.yml"
      to: "paperforge/plugin/"
      via: "Node 20 runner, working-directory: paperforge/plugin, npx vitest run"
      pattern: "vitest"

---

<objective>
Extract plugin source modules from main.js into testable src/ files, set up Vitest + obsidian-test-mocks + jsdom test infrastructure, write and pass plugin-backend integration tests (L3), and add Node 20 CI runner.

Purpose: Plugin runtime helpers, error classification, and command dispatch must have test coverage before v2.0 CI gates are finalized. Extracting pure functions from the monolithic main.js into src/ modules makes them testable without an Obsidian app instance.

Output:
- paperforge/plugin/src/runtime.js (resolvePythonExecutable, getPluginVersion, checkRuntimeVersion)
- paperforge/plugin/src/errors.js (classifyError, buildRuntimeInstallCommand, parseRuntimeStatus)
- paperforge/plugin/src/commands.js (ACTIONS, buildCommandArgs, runSubprocess)
- paperforge/plugin/package.json + vitest.config.ts
- tests/plugin/runtime.test.js, errors.test.js, commands.test.js
- .github/workflows/ci.yml updated with Node 20 job
</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/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/research/SUMMARY.md
@.planning/phases/53-doctor-verdict-surface/53-CONTEXT.md

<interfaces>
**Existing plugin functions to extract (from main.js):**

From resolvePythonExecutable (line 245-288):
- Manual override: checks settings.python_path, fs.existsSync
- Venv candidates: 3 common venv paths under vaultPath
- System candidates: py -3 / python / python3 with execFileSync --version
- Fallback: returns { path: 'python', source: 'auto-detected', extraArgs: [] }

From settings tab version check (line 1730-1756):
- execFile(pythonExe, [...extraArgs, '-c', 'import paperforge; print(paperforge.__version__)'])
- Compares pyVer.trim() with pluginVer from manifest.version
- Returns match (same), mismatch (different), not-installed (null)

From _syncRuntime (line 1930-1956):
- Constructs URL: `git+https://github.com/LLLin000/PaperForge.git@${version}`
- spawn(pythonExe, [...extraArgs, '-m', 'pip', 'install', '--upgrade', url])
- Returns exit code

From ACTIONS config (line 189-243):
- 6 action objects: sync, ocr, doctor, repair, context, collection-context
- Each has: id, title, desc, icon, cmd, okMsg, optional args/needsKey/needsFilter/disabled

From _runAction (line 1364-1500):
- resolvePythonExecutable -> resolve args -> spawn subprocess -> handle stdout/stderr/close/error
- Timeout: cmdTimeout based on action type (30000/60000/600000ms)

**Error patterns to classify:**
1. Python missing: all resolvePythonExecutable candidates fail, or execFile returns ENOENT
2. Import failed: execFile for `import paperforge` returns non-zero (ModuleNotFoundError)
3. Version mismatch: pyVer exists but !== pluginVer
4. Pip install failure: spawn exit code !== 0 during _syncRuntime
5. Timeout: execFile/spawn exceeds timeout, error.code === 'ETIMEDOUT'
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Extract testable src/ modules from main.js and set up Vitest infrastructure</name>
  <files>
    paperforge/plugin/src/runtime.js (NEW)
    paperforge/plugin/src/errors.js (NEW)
    paperforge/plugin/src/commands.js (NEW)
    paperforge/plugin/main.js (MODIFIED)
    paperforge/plugin/package.json (NEW)
    paperforge/plugin/vitest.config.ts (NEW)
  </files>
  <behavior>
    - runtime.js exports resolvePythonExecutable(vaultPath, settings): follows same manual→venv→system→fallback logic as line 245-288
    - runtime.js exports getPluginVersion(app): reads app.plugins.plugins['paperforge'].manifest.version
    - runtime.js exports checkRuntimeVersion(pythonExe, pluginVersion, cwd, timeout=10000): wraps execFile in Promise, returns {status: 'match'|'mismatch'|'not-installed', pyVersion, pluginVersion, error}
    - errors.js exports classifyError(code): maps error.code or string to one of 5 pattern enum values
    - errors.js exports buildRuntimeInstallCommand(pythonExe, version, extraArgs=[]): returns {cmd, args, url, timeout} for pip install git+https://...@version
    - errors.js exports parseRuntimeStatus(err, stdout, stderr): returns structured result from version check subprocess
    - commands.js exports ACTIONS: same array as main.js lines 189-243
    - commands.js exports buildCommandArgs(action, key, filter): resolves needsKey/needsFilter into extra args
    - commands.js exports runSubprocess(pythonExe, args, cwd, timeout): Promisified spawn with stdout/stderr capture
    - main.js requires from ./src/runtime, ./src/errors, ./src/commands instead of inline implementations
    - package.json scripts: { "test": "vitest run", "test:watch": "vitest" }
    - vitest.config.ts: test.environment='jsdom', include=['../../tests/plugin/**/*.test.js']
  </behavior>
  <action>
    1. Create `paperforge/plugin/src/runtime.js`:
       - Copy the resolvePythonExecutable function verbatim from main.js (lines 245-288), wrap as module.exports
       - Add getPluginVersion(app): returns app.plugins.plugins['paperforge']?.manifest?.version || null
       - Add checkRuntimeVersion(pythonExe, pluginVersion, cwd, timeout=10000):
         - Use execFile from 'node:child_process' as Promise<{status, pyVersion, pluginVersion, error}>
         - Run `pythonExe -c 'import paperforge; print(paperforge.__version__)'` in cwd
         - If err: return {status: 'not-installed', pyVersion: null, pluginVersion, error: err.message}
         - If stdout.trim() === pluginVersion: return {status: 'match', ...}
         - Else: return {status: 'mismatch', pyVersion: stdout.trim(), pluginVersion, error: null}

    2. Create `paperforge/plugin/src/errors.js`:
       - classifyError(errorCode): Map of 5 patterns:
         - 'ENOENT'|'python-missing' -> {type: 'python_missing', message: 'Python executable not found', recoverable: true}
         - 'MODULE_NOT_FOUND'|'import-failed' -> {type: 'import_failed', message: 'PaperForge package not installed', recoverable: true}
         - 'version-mismatch' -> {type: 'version_mismatch', message: 'Plugin and package versions differ', recoverable: true, action: 'sync-runtime'}
         - 'pip-failed' -> {type: 'pip_install_failure', message: 'pip install command failed', recoverable: true}
         - 'ETIMEDOUT'|'timeout' -> {type: 'timeout', message: 'Subprocess timed out', recoverable: true, action: 'retry'}
         - default -> {type: 'unknown', message: String(errorCode), recoverable: false}
       - buildRuntimeInstallCommand(pythonExe, version, extraArgs=[]):
         - url = `git+https://github.com/LLLin000/PaperForge.git@${version}`
         - args = [...extraArgs, '-m', 'pip', 'install', '--upgrade', url]
         - return {cmd: pythonExe, args, url, timeout: 120000}
       - parseRuntimeStatus(err, stdout, stderr):
         - No err + stdout -> parse version, compare with expected
         - err.code === 'ENOENT' -> python_missing classification
         - stdout includes 'No module named paperforge' -> import_failed
         - timeout -> timeout classification
         - return structured result

    3. Create `paperforge/plugin/src/commands.js`:
       - Copy ACTIONS array from main.js (lines 189-243) as module.exports
       - buildCommandArgs(action, key=null, filter=false):
         - args = Array.isArray(action.args) ? [...action.args] : []
         - if action.needsKey && key: args.push(key)
         - if action.needsFilter || filter: args.push('--all')
         - return args
       - runSubprocess(pythonExe, args, cwd, timeout):
         - Promisified spawn from 'node:child_process'
         - Returns {stdout, stderr, exitCode, elapsed}

    4. Modify `paperforge/plugin/main.js`:
       - Add at top: const { resolvePythonExecutable, getPluginVersion, checkRuntimeVersion } = require('./src/runtime');
       - Add: const { classifyError, buildRuntimeInstallCommand, parseRuntimeStatus } = require('./src/errors');
       - Add: const { ACTIONS, buildCommandArgs, runSubprocess } = require('./src/commands');
       - Remove inline resolvePythonExecutable function (line 245-288), ACTIONS array (line 189-243) — replaced by requires
       - Update _fetchVersion (line 407-429): use checkRuntimeVersion from src/ module
       - Update _syncRuntime (line 1930-1956): use buildRuntimeInstallCommand from src/ module
       - Verify no function redefinitions conflict

       IMPORTANT: Do NOT refactor _runAction, _buildPanel, PaperForgeStatusView, or PaperForgeSettingTab — keep those inline in main.js. Only extract the pure helper functions and constants.

    5. Create `paperforge/plugin/package.json`:
       ```json
       {
         "name": "paperforge-plugin",
         "version": "1.0.0",
         "private": true,
         "type": "commonjs",
         "scripts": {
           "test": "vitest run",
           "test:watch": "vitest"
         },
         "devDependencies": {
           "vitest": "^2.0.0",
           "obsidian-test-mocks": "^0.12.0",
           "jsdom": "^24.0.0",
           "obsidian": "^1.9.0"
         }
       }
       ```

    6. Create `paperforge/plugin/vitest.config.ts`:
       ```ts
       import { defineConfig } from 'vitest/config';
       export default defineConfig({
         test: {
           environment: 'jsdom',
           include: ['../../tests/plugin/**/*.test.js'],
           globals: true,
         },
       });
       ```

    7. Run `cd paperforge/plugin && npm install` to install dev dependencies. Verify vitest is available at node_modules/.bin/vitest.
  </action>
  <verify>
    <automated>cd paperforge/plugin && node -e "require('./src/runtime'); require('./src/errors'); require('./src/commands'); console.log('Modules load OK')"</automated>
  </verify>
  <done>
    - `paperforge/plugin/src/runtime.js` exists with 3 exported functions
    - `paperforge/plugin/src/errors.js` exists with 3 exported functions
    - `paperforge/plugin/src/commands.js` exists with 3 exported functions
    - `main.js` requires all 3 src/ modules and still loads without syntax errors
    - `package.json` exists with vitest + obsidian-test-mocks in devDependencies
    - `npm install` completes without errors
    - Node can require all 3 modules
  </done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Write and pass plugin Vitest tests for runtime helpers, error classification, and command dispatch</name>
  <files>
    tests/plugin/runtime.test.js (NEW)
    tests/plugin/errors.test.js (NEW)
    tests/plugin/commands.test.js (NEW)
  </files>
  <behavior>
    - runtime.test.js: resolvePythonExecutable returns correct {path, source, extraArgs} for manual, venv, system paths and fallback
    - runtime.test.js: getPluginVersion returns version from mock app.plugins
    - runtime.test.js: checkRuntimeVersion returns match/mismatch/not-installed based on mock execFile output
    - errors.test.js: classifyError maps all 5 error patterns + fallback for unknown
    - errors.test.js: buildRuntimeInstallCommand returns correct URL structure with version tag
    - errors.test.js: parseRuntimeStatus handles success, ENOENT, ModuleNotFoundError, timeout
    - commands.test.js: ACTIONS has correct structure (id, title, cmd for all 6 entries)
    - commands.test.js: buildCommandArgs handles needsKey, needsFilter, and no-flags cases
    - commands.test.js: runSubprocess returns {stdout, stderr, exitCode} from mock child_process
  </behavior>
  <action>
    1. Create `tests/plugin/runtime.test.js`:
       - Use `vi.mock('fs')` and `vi.mock('node:child_process')` to isolate
       - Test resolvePythonExecutable (4 test cases):
         a. Manual override: settings.python_path set to valid path -> returns {path: thatPath, source: 'manual'}
         b. Venv detection: vaultPath has .paperforge-test-venv/Scripts/python.exe -> returns venv path, source: 'auto-detected'
         c. System candidate: no manual, no venv, py -3 returns version string -> returns {path: 'py', extraArgs: ['-3']}
         d. Fallback: no candidates found -> returns {path: 'python', source: 'auto-detected', extraArgs: []}
       - Test getPluginVersion (2 cases):
         a. Plugin loaded -> returns version string from manifest
         b. Plugin not found -> returns null
       - Test checkRuntimeVersion (3 cases):
         a. Version matches -> {status: 'match', pyVersion, pluginVersion}
         b. Version differs -> {status: 'mismatch', pyVersion, pluginVersion}
         c. execFile error (ENOENT) -> {status: 'not-installed', error: ...}

       Mock approach:
       - For fs.existsSync: mock return true/false for specific paths
       - For execFileSync (used in resolvePythonExecutable): mock to return version string or throw
       - For execFile in checkRuntimeVersion: wrap in mock that calls callback with (null, stdout) or (err)

    2. Create `tests/plugin/errors.test.js`:
       - Test classifyError (6 cases):
         a. 'ENOENT' -> {type: 'python_missing', recoverable: true}
         b. 'MODULE_NOT_FOUND' -> {type: 'import_failed', recoverable: true}
         c. 'version-mismatch' -> {type: 'version_mismatch', recoverable: true, action: 'sync-runtime'}
         d. 'pip-failed' -> {type: 'pip_install_failure', recoverable: true}
         e. 'ETIMEDOUT' -> {type: 'timeout', recoverable: true, action: 'retry'}
         f. 'unknown-string' -> {type: 'unknown', recoverable: false}
       - Test buildRuntimeInstallCommand:
         a. constructs URL with version tag: `...git+https://...@v1.4.17rc3`
         b. includes extraArgs if provided
         c. timeout is 120000
       - Test parseRuntimeStatus (4 cases):
         a. Success: err=null, stdout='1.4.17rc3\n' -> {status: 'ok', version: '1.4.17rc3'}
         b. ENOENT: err.code='ENOENT' -> classified as python_missing
         c. ImportError: stderr includes 'No module named paperforge' -> import_failed
         d. Timeout: err.killed=true -> timeout classification

    3. Create `tests/plugin/commands.test.js`:
       - Test ACTIONS structure:
         a. Has exactly 6 entries
         b. Each entry has id, title, cmd, okMsg
         c. 'paperforge-sync' has cmd: 'sync'
         d. 'paperforge-repair' has disabled: true
         e. 'paperforge-copy-context' has needsKey: true
       - Test buildCommandArgs (3 cases):
         a. needsKey with key -> appends key to args
         b. needsFilter -> appends '--all' to args
         c. No flags -> returns empty array
       - Test runSubprocess: mock spawn, verify stdout/stderr/exitCode captured

    4. Run all tests:
       ```bash
       cd paperforge/plugin && npx vitest run
       ```
       Fix any failures. Expected: 13+ tests passing.
  </action>
  <verify>
    <automated>cd paperforge/plugin && npx vitest run --reporter=verbose 2>&1</automated>
  </verify>
  <done>
    - `tests/plugin/runtime.test.js` has 9+ passing tests covering all resolvePythonExecutable paths + version logic
    - `tests/plugin/errors.test.js` has 11+ passing tests covering 5 error patterns + command builder + parser
    - `tests/plugin/commands.test.js` has 8+ passing tests covering ACTIONS + arg builder + subprocess
    - `npx vitest run` exits 0, all tests green
  </done>
</task>

<task type="auto">
  <name>Task 3: Add Node 20 CI runner for plugin Vitest tests</name>
  <files>
    .github/workflows/ci.yml (NEW or MODIFIED)
  </files>
  <action>
    1. Create `.github/workflows/ci.yml` (or append to existing if one exists):
       - Add a job `plugin-tests` under `jobs:`:
         ```yaml
       plugin-tests:
         name: Plugin Tests (L3)
         runs-on: ubuntu-latest
         steps:
           - uses: actions/checkout@v4
           - uses: actions/setup-node@v4
             with:
               node-version: 20
               cache: 'npm'
               cache-dependency-path: paperforge/plugin/package-lock.json
           - run: npm ci
             working-directory: paperforge/plugin
           - run: npx vitest run --reporter=verbose
             working-directory: paperforge/plugin
         ```

       - If no ci.yml exists yet, create it with just this job and a `name` field.
       - Add a descriptive `if:` condition to run only when plugin files change (path filter):
         ```yaml
         if: contains(join(github.event.head_commit.modified, ' '), 'paperforge/plugin/') || contains(join(github.event.head_commit.added, ' '), 'paperforge/plugin/') || github.ref == 'refs/heads/main'
         ```

       Note: If there is no `.github/workflows/` directory, create it. Use `path-filter` from `dorny/paths-filter` or a simpler approach if this is the first workflow.

    2. Verify the workflow is well-formed YAML:
       ```bash
       node -e "require('js-yaml').load(require('fs').readFileSync('.github/workflows/ci.yml','utf8'))"
       ```
       (If `js-yaml` is not available, use a simple structural check instead.)
  </action>
  <verify>
    <automated>Select-String -SimpleMatch "vitest" -Path "D:\L\Med\Research\99_System\LiteraturePipeline\github-release\.github\workflows\ci.yml" -ErrorAction SilentlyContinue; if ($?) { Write-Output "CI file has vitest runner" } else { Write-Output "CI file not present yet (created during execution)" }  </verify>
  <done>
    - .github/workflows/ci.yml exists with plugin-tests job using Node 20
    - Job runs 
pm ci and 
px vitest run in paperforge/plugin/ directory
    - YAML is well-formed and parseable
  </done>
</task>

</tasks>

<verification>
1. All 3 plugin test files pass: cd paperforge/plugin && npx vitest run
2. All 3 src/ modules load correctly: 
ode -e "require('./src/runtime'); require('./src/errors'); require('./src/commands')"
3. main.js still loads without errors after replacing inline functions with src/ requires
4. CI workflow YAML is valid and references vitest correctly
</verification>

<success_criteria>
1. resolvePythonExecutable, getPluginVersion, checkRuntimeVersion extracted to src/runtime.js and have passing Vitest tests
2. classifyError, buildRuntimeInstallCommand, parseRuntimeStatus extracted to src/errors.js and have passing Vitest tests
3. ACTIONS, buildCommandArgs, runSubprocess extracted to src/commands.js and have passing Vitest tests
4. main.js imports all 3 src/ modules and continues to work in Obsidian (no behavioral regression, verified by Node require)
5. 	ests/plugin/ runs via 
px vitest run with 28+ tests passing
6. .github/workflows/ci.yml has Node 20 job executing plugin Vitest tests (CI-04)
</success_criteria>

<output>
After completion, create .planning/phases/53-doctor-verdict-surface/53-001-SUMMARY.md
</output>