---
phase: 25-surface-convergence-doctor-repair
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
  - paperforge/plugin/main.js
autonomous: true
requirements:
  - SURF-03

must_haves:
  truths:
    - "Plugin dashboard reads formal-library.json directly via readFileSync() instead of spawning python CLI"
    - "Plugin dashboard reads only envelope metadata and summary fields, not the full items list"
    - "Plugin dashboard falls back to CLI spawn when formal-library.json is missing"
    - "Plugin dashboard has Quick Actions for doctor and repair as thin CLI shells"
    - "Existing _fetchStats() is refactored to direct JSON read pattern"
  artifacts:
    - path: paperforge/plugin/main.js
      provides: "Refactored _fetchStats() using direct JSON file read; doctor/repair Quick Action buttons"
  key_links:
    - from: paperforge/plugin/main.js:_fetchStats
      to: paperforge/worker/asset_index.get_index_path (resolved index JSON path)
      via: "Read formal-library.json from the resolved index path using readFileSync()"
      pattern: "readFileSync|readFile.*formal-library"
    - from: paperforge/plugin/main.js:ACTIONS
      to: paperforge CLI commands
      via: "spawn python -m paperforge {cmd}"
      pattern: "spawn.*paperforge (doctor|repair)"
---

<objective>
Refactor the Plugin dashboard to read the canonical index JSON file directly instead of spawning a Python CLI process. Add doctor and repair as Quick Action buttons in the dashboard. The plugin remains a thin shell over CLI commands and canonical index outputs — no JavaScript re-implementation of lifecycle or health rules.

Purpose: Plugin dashboard must consume the same lifecycle/health/maturity data from the canonical index as CLI status does (SURF-03, per D-05, D-06, D-07). Dashboard actions remain thin shells over CLI commands (D-05 principle).

Output:
- Refactored `paperforge/plugin/main.js` — _fetchStats reads formal-library.json directly; ACTIONS includes doctor and repair
</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/STATE.md
@.planning/ROADMAP.md
@.planning/phases/25-surface-convergence-doctor-repair/25-CONTEXT.md
@paperforge/plugin/main.js
@paperforge/worker/asset_index.py

<interfaces>
From paperforge/worker/asset_index.py:
```python
INDEX_FILENAME = "formal-library.json"   # Filename of the canonical asset index
def get_index_path(vault: Path) -> Path  # Returns: <paperforge>/indexes/formal-library.json
```

From paperforge/config.py (plugin reads paperforge.json for paths):
- `system_dir` key: e.g. "99_System"
- `paperforge_dir` or derived: e.g. "99_System/PaperForge"
- Index resolves to: `{vault}/{system_dir}/PaperForge/indexes/formal-library.json`

Current plugin ACTIONS array (main.js:155-172):
```javascript
const ACTIONS = [
    {
        id: 'paperforge-sync',
        title: 'Sync Library',
        desc: 'Pull new references from Zotero and generate literature notes',
        icon: '\u21BB',  // ↻
        cmd: 'sync',
        okMsg: 'Sync complete',
    },
    {
        id: 'paperforge-ocr',
        title: 'Run OCR',
        desc: 'Extract full text and figures from PDFs via PaddleOCR',
        icon: '\u229E',  // ⊞
        cmd: 'ocr',
        okMsg: 'OCR started',
    },
];
```

Current _fetchStats() (main.js:249-276):
```javascript
_fetchStats(quiet) {
    // ... shows loading, then spawns python -m paperforge status --json
    const vp = this.app.vault.adapter.basePath;
    exec('python -m paperforge status --json', { cwd: vp, timeout: 30000 }, (err, stdout) => {
        if (err) { /* fallback to cached */ return; }
        try {
            const d = JSON.parse(stdout);
            this._cachedStats = d;
            this._metricsEl.empty();
            this._renderStats(d);
            this._renderOcr(d);
        } catch { /* ... */ }
    });
}
```

Current _renderStats() (main.js:278-293) shows three metric cards: Papers, Notes, Exports.
</interfaces>
</context>

<tasks>

<task type="auto" tdd="false">
<name>Task 1: Refactor _fetchStats() to read canonical index JSON directly</name>
<files>
  paperforge/plugin/main.js
</files>

<read_first>
  paperforge/plugin/main.js:249-293 (_fetchStats and _renderStats)
  paperforge/worker/asset_index.py:61-69 (get_index_path)
</read_first>

<action>
Refactor `_fetchStats()` and `_renderStats()` in `paperforge/plugin/main.js` to read `formal-library.json` directly from the filesystem per D-05, D-06, D-07.

**D-05:** Plugin reads `formal-library.json` directly via `readFileSync()` instead of spawning `python -m paperforge status --json`.
**D-06:** Only the envelope metadata and summary fields are read (not full items list) — keeps dashboard fast.
**D-07:** Existing `_fetchStats()` method refactored to read the JSON file directly, falling back to Python CLI if file is missing.

**Implementation steps:**

1. Replace the body of `_fetchStats()` to follow this new logic:
   ```javascript
   _fetchStats(quiet) {
       // Phase 25: Read canonical index JSON directly (D-05)
       const vp = this.app.vault.adapter.basePath;
       const indexPath = path.join(vp, getSetting('system_dir') || '99_System', 'PaperForge', 'indexes', 'formal-library.json');
       
       try {
           const raw = fs.readFileSync(indexPath, 'utf-8');
           const index = JSON.parse(raw);
           const items = index.items || [];
           
           // D-06: Only read envelope metadata and summary — NOT full items list in memory
           // For the dashboard, we compute the summary from the items list
           // but limit to what's needed: counts and aggregates
           const paperCount = items.length;
           const lifecycleCounts = { indexed: 0, pdf_ready: 0, fulltext_ready: 0, deep_read_done: 0, ai_context_ready: 0 };
           const healthCounts = { pdf_health: { healthy: 0, unhealthy: 0 }, ocr_health: { healthy: 0, unhealthy: 0 }, note_health: { healthy: 0, unhealthy: 0 }, asset_health: { healthy: 0, unhealthy: 0 } };
           let ocrTotal = 0, ocrDone = 0, ocrPending = 0, ocrProcessing = 0, ocrFailed = 0;
           
           for (const item of items) {
               const lifecycle = item.lifecycle || 'pdf_ready';
               lifecycleCounts[lifecycle] = (lifecycleCounts[lifecycle] || 0) + 1;
               
               const health = item.health || {};
               for (const dim of ['pdf_health', 'ocr_health', 'note_health', 'asset_health']) {
                   const val = health[dim] || 'healthy';
                   if (val === 'healthy') healthCounts[dim].healthy++;
                   else healthCounts[dim].unhealthy++;
               }
               
               // OCR counts from index (supersedes CLI-spawned OCR parsing)
               const ocrStatus = item.ocr_status || '';
               ocrTotal++;
               if (ocrStatus === 'done') ocrDone++;
               else if (ocrStatus === 'pending') ocrPending++;
               else if (ocrStatus === 'processing' || ocrStatus === 'queued' || ocrStatus === 'running') ocrProcessing++;
               else ocrFailed++;
           }
           
           this._cachedStats = {
               version: index.schema_version || index.generated_at ? (
                   // try to get version from index metadata, fallback to cached
                   this._cachedStats?.version || '\u2014'
               ) : this._cachedStats?.version || '\u2014',
               total_papers: paperCount,
               formal_notes: items.filter(i => i.note_path).length,
               exports: 0,  // no longer fetched — can be removed or shown as static
               bases: 0,    // no longer fetched
               ocr: { total: ocrTotal, pending: ocrPending, processing: ocrProcessing, done: ocrDone, failed: ocrFailed },
               path_errors: 0, // no longer fetched individually
               lifecycle_level_counts: lifecycleCounts,
               health_aggregate: healthCounts,
           };
           
           this._metricsEl.empty();
           this._renderStats(this._cachedStats);
           this._renderOcr(this._cachedStats);
       } catch (err) {
           // D-07: Fallback: spawn CLI if file is missing or corrupt
           if (!quiet && !this._cachedStats) {
               this._metricsEl.createEl('div', { cls: 'paperforge-status-loading', text: 'No index — trying CLI...' });
           }
           exec('python -m paperforge status --json', { cwd: vp, timeout: 30000 }, (err2, stdout) => {
               if (err2) {
                   if (this._cachedStats) return;
                   this._metricsEl.createEl('div', { cls: 'paperforge-status-error', text: 'Cannot reach PaperForge CLI.\nMake sure paperforge is installed and in your PATH.' });
                   return;
               }
               try {
                   const d = JSON.parse(stdout);
                   this._cachedStats = d;
                   this._metricsEl.empty();
                   this._renderStats(d);
                   this._renderOcr(d);
               } catch {
                   if (!this._cachedStats) {
                       this._metricsEl.createEl('div', { cls: 'paperforge-status-error', text: 'Invalid response from paperforge status.' });
                   }
               }
           });
       }
   }
   ```

2. Import `fs` and `path` at the top of main.js (if not already present):
   ```javascript
   const fs = require('fs');
   const path = require('path');
   ```
   Add these alongside the existing `const { exec } = require('node:child_process');` import.

3. Get `system_dir` setting: Use `this.settings?.system_dir || '99_System'`. The settings are loaded at plugin init and available via `this.settings`.

4. **CRITICAL — keep dashboard fast per D-06:** The items loop computes counts WITHOUT storing the items array in memory (single-pass aggregation). After the loop, items can be garbage-collected. No `items` reference is held beyond the loop scope.

5. Update `_renderStats()` to show lifecycle/h health info when available:
   - If `d.lifecycle_level_counts` exists, add a new "Lifecycle" metric card showing paper count
   - If `d.health_aggregate` exists, add health summaries as secondary info
   - Keep the existing 3 metric cards (Papers, Notes, Exports) — but note that `formal_notes` now comes from index (count of items with note_path) instead of CLI. `exports` and `bases` are set to 0 in the index path (no longer scanned) — update `_renderStats` to handle this gracefully (show '\u2014' if value is 0 and index-sourced, to indicate "from index" rather than "zero").
   - Wait, that's confusing. Better approach: keep showing the values. When index-backed, `exports` and `bases` are approximations or omitted. Let me refine:

   Actually let me keep it simpler. The key stats we get from the index are: total_papers, lifecycle counts, health aggregates, and OCR counts. The `bases` and `exports` counts are legacy CLI outputs that aren't critical. The `_renderStats` already only shows Papers, Notes, Exports — which is fine. For the index-backed path:
   - `total_papers` = paperCount (from index items)
   - `formal_notes` = items with note_path
   - `exports` = keep as 0 or estimate from index... actually `_renderStats` just shows these values in cards. Let's keep it unchanged — the values come from the `_cachedStats` object shape which includes these fields. The index path just populates them differently.
   
   Keep it simple: populating `_cachedStats` with the same shape as before. `exports` and `bases` will be 0 from the index path (acceptable — they're minor informational fields). The key fields (`total_papers`, `formal_notes`, `ocr`) are populated correctly.

6. Verify the `_renderOcr(d)` function (lines 296-361) works with the index-derived OCR counts. The shape is the same: `d.ocr = { total, pending, processing, done, failed }`. No changes needed.

No automated tests for plugin JS in the existing test suite. Verification is manual via Obsidian reload. Write a small Node.js verification script if needed.
</action>

<acceptance_criteria>
- `main.js` `_fetchStats()` reads `formal-library.json` via `readFileSync` as primary path
- Fallback to CLI spawn when index file is missing
- OCR counts derived from index entries, showing same shape as before
- Plugin renders metric cards correctly from index data
</acceptance_criteria>

<verify>
  <automated>node -e "const fs=require('fs'); const p=require('path'); console.log('fs+path imports OK — modules available for plugin')"</automated>
</verify>

<done>
_fetchStats reads formal-library.json directly. Falls back to CLI. Dashboard shows correct counts from canonical index.
</done>
</task>

<task type="auto" tdd="false">
<name>Task 2: Add doctor and repair as plugin Quick Action buttons</name>
<files>
  paperforge/plugin/main.js
</files>

<read_first>
  paperforge/plugin/main.js:155-172 (ACTIONS array), 364-412 (_runAction method)
</read_first>

<action>
Add two new Quick Action entries to the `ACTIONS` array in `paperforge/plugin/main.js`, after the existing `paperforge-ocr` entry:

```javascript
{
    id: 'paperforge-doctor',
    title: 'Run Doctor',
    desc: 'Verify PaperForge setup — check configs, Zotero, paths, and index health',
    icon: '\u2695',  // ⚕ (stethoscope-like)
    cmd: 'doctor',
    okMsg: 'Doctor complete',
},
{
    id: 'paperforge-repair',
    title: 'Repair Issues',
    desc: 'Fix three-way state divergence, path errors, and rebuild index',
    icon: '\u21BA',  // ↺
    cmd: 'repair',
    okMsg: 'Repair complete',
},
```

These are thin shells over CLI commands per SURF-03 / D-05 principle. The existing `_runAction()` method at line 365 already handles spawning `python -m paperforge {cmd}` — no changes needed to the execution logic.

The `doctor` and `repair` CLI commands are already registered in `paperforge/cli.py`:
- `paperforge doctor` → calls `run_doctor()`
- `paperforge repair` → calls `run_repair()`

No changes to `_runAction()` required. Verify that the existing `stdout`/`stderr` logging and error handling in `_runAction()` work for these new commands (they produce text output, which the current `_runAction` already handles via `child.stdout.on('data', ...)`).

Also verify `_renderStats()` has enough room — the actions grid now has 4 cards instead of 2. The `paperforge-actions-grid` CSS class should handle this (flex layout), but if the grid looks cramped, add a `min-width` to `.paperforge-action-card` in the plugin CSS to accommodate 4 cards. Check the plugin `styles.css`:
- If the card width is hardcoded to 50%, change to `min-width: calc(50% - 8px)` to auto-wrap to 2-column layout.
- If using flexbox with `flex: 1`, it should auto-adapt.
</action>

<acceptance_criteria>
- `ACTIONS` array has 4 entries: sync, ocr, doctor, repair
- Each new action has valid `id`, `title`, `desc`, `icon`, `cmd`, `okMsg`
- Doctor action runs `python -m paperforge doctor`
- Repair action runs `python -m paperforge repair`
- Actions grid adapts to 4 cards without breaking layout
</acceptance_criteria>

<verify>
  <automated>node -e "const fs=require('fs'); const js=fs.readFileSync('paperforge/plugin/main.js','utf-8'); const actions=js.match(/id:\s*'paperforge-(sync|ocr|doctor|repair)'/g); console.log('Actions found:', actions.length); actions.forEach(a=&gt;console.log(' -', a))"</automated>
</verify>

<done>
ACTIONS array has 4 entries including doctor and repair. Dashboard shows 4 Quick Action cards.
</done>
</task>

</tasks>

<verification>
1. Node.js check: `node -e "const fs=require('fs'); const js=fs.readFileSync('paperforge/plugin/main.js','utf-8'); const m=js.match(/readFileSync.*formal-library/); console.log('Direct JSON read:', !!m)"` — verifies direct JSON read pattern
2. Verify ACTIONS array: grep for `id: 'paperforge-doctor'` and `id: 'paperforge-repair'` in main.js
3. Manual: Open Obsidian, reload plugin, verify dashboard shows stats from canonical index
4. Manual: Verify Quick Actions show 4 cards including doctor and repair
</verification>

<success_criteria>
- SURF-03: Plugin dashboard reads formal-library.json directly (no CLI spawn needed for normal operation)
- SURF-03: Dashboard actions are thin CLI shells — no JavaScript re-implementation of lifecycle or health rules
- Plugin dashboard shows same lifecycle/health/maturity semantics as CLI status
- Doctor and repair available as one-click actions from dashboard
</success_criteria>

<output>
After completion, create `.planning/phases/25-surface-convergence-doctor-repair/25-02-SUMMARY.md`
</output>