---
phase: 26-traceable-ai-context-packs
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - paperforge/worker/sync.py (add migrate_to_workspace function)
  - paperforge/worker/asset_index.py (update _build_entry to write to workspace paths)
  - tests/test_migration.py
autonomous: true
requirements:
  - AIC-02
  - AIC-03
  - AIC-04

must_haves:
  truths:
    - "Existing flat Literature/<domain>/<key> - <Title>.md notes are copied into workspace directories on first sync"
    - "## 🔍 精读 section is extracted from the main note and written as separate deep-reading.md in the workspace"
    - "Migration is idempotent: already-migrated papers are skipped on re-sync"
    - "The original flat note file is preserved (copy-not-move)"
    - "Canonical index entries' note_path fields point to the new workspace location"
    - "New papers (first sync without a prior flat note) create workspace structure directly"
    - "ai/ directory is created inside each paper workspace"
  artifacts:
    - path: paperforge/worker/sync.py
      provides: "migrate_to_workspace() function — copies flat notes to workspace dir, splits deep-reading"
    - path: paperforge/worker/asset_index.py
      provides: "Updated _build_entry writes notes to workspace path instead of flat path"
    - path: tests/test_migration.py
      provides: "Tests for migration function, _build_entry workspace write, and idempotency"
  key_links:
    - from: "sync.py:run_index_refresh"
      to: "sync.py:migrate_to_workspace"
      via: "call migrate_to_workspace() before build_index() in run_index_refresh"
      pattern: "migrate_to_workspace"
    - from: "asset_index.py:_build_entry"
      to: "workspace main_note_path"
      via: "write note to Literature/<domain>/<key> - <slug>/<key> - <slug>.md with ai/ dir"
      pattern: "main_note_path|paper_root"
---

<objective>
Implement flat-to-workspace note migration so that the canonical index's declared workspace paths (`paper_root`, `main_note_path`, `deep_reading_path`, `ai_path`) point to real files instead of hypothetical locations. This migration is a prerequisite for AI context packs (AIC-02, AIC-03, AIC-04) because the context pack must reference workspace-structured assets.

Purpose: Existing Phase 22-25 code declares workspace paths in the canonical index entry (e.g. `Literature/<domain>/<key> - <slug>/<key> - <slug>.md`) but `_build_entry` still writes notes to the flat path (`Literature/<domain>/<key> - <slug>.md`). Without migration, the workspace paths declared in the index point to nonexistent files, breaking traceability for AI context packs.

Output:
- `paperforge/worker/sync.py` — new `migrate_to_workspace()` function
- `paperforge/worker/asset_index.py` — updated `_build_entry()` writes to workspace path
- `tests/test_migration.py` — migration 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/STATE.md
@.planning/ROADMAP.md
@.planning/phases/26-traceable-ai-context-packs/26-CONTEXT.md

<interfaces>
From paperforge/worker/asset_index.py — current _build_entry pattern:
```python
# Line 253 — note path is always flat:
note_path = paths["literature"] / domain / f"{key} - {title_slug}.md"
# Line 296-301 — workspace paths declared in entry but never actually created:
"paper_root": f"Literature/{domain}/{key} - {title_slug}/",
"main_note_path": f"Literature/{domain}/{key} - {title_slug}/{key} - {title_slug}.md",
"fulltext_path": f"Literature/{domain}/{key} - {title_slug}/fulltext.md",
"deep_reading_path": f"Literature/{domain}/{key} - {title_slug}/deep-reading.md",
"ai_path": f"Literature/{domain}/{key} - {title_slug}/ai/",
```

From paperforge/worker/sync.py — deep-reading extraction helper:
```python
# Line 564
DEEP_READING_HEADER = "## 🔍 精读"
# Line 567
def extract_preserved_deep_reading(text: str) -> str:
    # Extracts ## 🔍 精读 section via regex
    match = re.search("^## 🔍 精读\\s*$", text, re.MULTILINE)
    if not match: return ""
    return text[match.start():].strip()
# Line 583
def has_deep_reading_content(text: str) -> bool:
    # Returns True if deep-reading section has substantive content (not just scaffolds)
```

From paperforge/worker/sync.py — run_index_refresh pattern:
```python
# Line ~? — current sync flow
def run_index_refresh(vault, verbose=False, rebuild_index=False) -> int:
    # calls build_index(vault, verbose) or refresh_index_entry
```

From paperforge/worker/asset_index.py — build_index pattern:
```python
# Line 322
def build_index(vault: Path, verbose: bool = False) -> int:
    # Iterates export JSONs, calls _build_entry for each item
    # Writes envelope via atomic_write_index
```

From paperforge/worker/asset_index.py — _build_entry writes note at line 310-312:
```python
note_path.parent.mkdir(parents=True, exist_ok=True)
existing_text = note_path.read_text(encoding="utf-8") if note_path.exists() else ""
note_path.write_text(frontmatter_note(entry, existing_text), encoding="utf-8")
```

From paperforge/worker/sync.py — frontmatter_note signatures (line 1598):
```python
def frontmatter_note(entry: dict, existing_text: str = "") -> str:
    # Generates full markdown note from entry dict + preserves deep-reading if present
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="false">
<name>Task 1: Add migrate_to_workspace() and update _build_entry() for workspace path writing</name>
<files>
  paperforge/worker/sync.py
  paperforge/worker/asset_index.py
</files>

<read_first>
  paperforge/worker/sync.py (add migration function near top of file, before run_index_refresh)
  paperforge/worker/asset_index.py:210-314 (current _build_entry implementation — modify note writing logic)
</read_first>

<action>
Perform two coordinated changes:

**Change A — Add `migrate_to_workspace(vault: Path, paths: dict) -> int` to sync.py:**

This function scans the canonical index and migrates any paper whose flat note exists but workspace directory does not. It is called by `run_index_refresh()` BEFORE `build_index()`.

```python
def migrate_to_workspace(vault: Path, paths: dict) -> int:
    """Migrate flat literature notes into paper workspace directories.
    
    Copies each flat note at Literature/<domain>/<key> - <Title>.md into:
      Literature/<domain>/<key> - <Title>/<key> - <Title>.md
    Extracts ## 🔍 精读 into:
      Literature/<domain>/<key> - <Title>/deep-reading.md
    Creates:
      Literature/<domain>/<key> - <Title>/ai/  (empty directory)
    
    Idempotent: skips papers whose workspace directory already exists.
    The original flat note is preserved (copy-not-move per D-12).
    
    Returns: number of papers migrated (0 means all are already workspace).
    """
    from paperforge.worker.asset_index import read_index, get_index_path
    from paperforge.worker.sync import extract_preserved_deep_reading
    
    index = read_index(vault)
    if index is None:
        return 0  # No index yet — nothing to migrate
    
    items = index.get("items", []) if isinstance(index, dict) else []
    migrated = 0
    
    for entry in items:
        key = entry.get("zotero_key", "")
        domain = entry.get("domain", "")
        title = entry.get("title", "")
        if not key or not domain:
            continue
        
        # Compute paths using the same slugify as _build_entry
        from paperforge.worker._utils import slugify_filename
        title_slug = slugify_filename(title)
        workspace_dir = paths["literature"] / domain / f"{key} - {title_slug}"
        
        # Skip if already migrated
        if workspace_dir.exists():
            continue
        
        # Find the flat note path
        flat_note_path = paths["literature"] / domain / f"{key} - {title_slug}.md"
        if not flat_note_path.exists():
            # Try other potential flat note paths (e.g. slightly different slug)
            candidates = list((paths["literature"] / domain).glob(f"{key} - *.md"))
            if candidates:
                flat_note_path = candidates[0]
            else:
                continue  # No flat note to migrate
        
        # Create workspace directory
        workspace_dir.mkdir(parents=True, exist_ok=True)
        
        # Read flat note content
        content = flat_note_path.read_text(encoding="utf-8")
        
        # Write main note to workspace (copy of flat note)
        main_note_path = workspace_dir / f"{key} - {title_slug}.md"
        main_note_path.write_text(content, encoding="utf-8")
        
        # Extract deep-reading section and write to separate file
        preserved = extract_preserved_deep_reading(content)
        if preserved:
            deep_reading_path = workspace_dir / "deep-reading.md"
            deep_reading_path.write_text(preserved, encoding="utf-8")
        
        # Create ai/ directory
        ai_dir = workspace_dir / "ai"
        ai_dir.mkdir(exist_ok=True)
        
        migrated += 1
    
    if migrated > 0:
        print(f"migrate_to_workspace: migrated {migrated} paper(s) to workspace structure")
    
    return migrated
```

**Change B — Update `_build_entry` in asset_index.py to write notes to workspace path:**

Modify the note-writing section of `_build_entry()` (around line 253-312) per D-11 through D-15:

1. Compute the workspace `main_note_path` using the same formula as the entry's `main_note_path` field:
   ```python
   workspace_dir = paths["literature"] / domain / f"{key} - {title_slug}"
   main_note_path = workspace_dir / f"{key} - {title_slug}.md"
   ```

2. Determine the write target and existing-text source:
   ```python
   # If workspace dir exists, read from and write to workspace (migrated or direct)
   if workspace_dir.exists():
       existing_text = main_note_path.read_text(encoding="utf-8") if main_note_path.exists() else ""
       write_target = main_note_path
       # Ensure subdirs exist
       for d in [workspace_dir, workspace_dir / "ai"]:
           d.mkdir(parents=True, exist_ok=True)
   else:
       # Legacy: read from flat path, write to flat path (transitional)
       existing_text = note_path.read_text(encoding="utf-8") if note_path.exists() else ""
       write_target = note_path
       note_path.parent.mkdir(parents=True, exist_ok=True)
   ```

3. Write the note:
   ```python
   write_target.write_text(frontmatter_note(entry, existing_text), encoding="utf-8")
   ```

4. Keep the entry's workspace path fields unchanged (they're already correct):
   ```python
   "paper_root": f"Literature/{domain}/{key} - {title_slug}/",
   "main_note_path": f"Literature/{domain}/{key} - {title_slug}/{key} - {title_slug}.md",
   # etc.
   ```

**Change C — Wire migration into `run_index_refresh()` in sync.py:**

Inside `run_index_refresh()`, BEFORE the `build_index()` call:
```python
from paperforge.worker._utils import pipeline_paths
paths = pipeline_paths(vault)
migrate_to_workspace(vault, paths)  # Per D-11: migrate on first sync
```

This ensures flat notes are copied to workspace before _build_entry runs, which makes `_build_entry` see the workspace dir and write updates there.

**Change D — Remove `note_path` field from entry (optional):**
Keep `note_path` as-is for backward compatibility (flat path in entry). The workspace paths (`main_note_path`, `paper_root`) are the canonical paths. `note_path` can be deprecated in a future phase.

Acceptance criteria:
- `sync.py` contains `def migrate_to_workspace(vault: Path, paths: dict) -> int:`
- `migrate_to_workspace` is called inside `run_index_refresh()` before `build_index()`
- `_build_entry` in `asset_index.py` writes to workspace path when workspace dir exists
- `_build_entry` creates workspace subdirs (including `ai/`) when writing to workspace
- When workspace dir does NOT exist, `_build_entry` falls back to flat path writing (backward compat)
</action>

<acceptance_criteria>
- `paperforge/worker/sync.py` contains `def migrate_to_workspace(vault: Path, paths: dict) -> int:`
- `run_index_refresh()` calls `migrate_to_workspace()` before `build_index()`
- `paperforge/worker/asset_index.py` `_build_entry()` writes note to workspace path when workspace dir exists
- `_build_entry()` creates `ai/` dir when writing to workspace
- Flat path fallback preserved when workspace dir does NOT exist
</acceptance_criteria>

<verify>
  <automated>python -c "from paperforge.worker.sync import migrate_to_workspace; print('migrate_to_workspace importable')"</automated>
</verify>

<done>
migrate_to_workspace() exists in sync.py, is wired into run_index_refresh(), and _build_entry writes to workspace paths when workspace dir exists. Flat path fallback preserved.
</done>
</task>

<task type="auto" tdd="false">
<name>Task 2: Write tests for migration function, _build_entry workspace write, and idempotency</name>
<files>
  tests/test_migration.py
</files>

<read_first>
  paperforge/worker/sync.py (migrate_to_workspace from Task 1)
  paperforge/worker/asset_index.py (updated _build_entry from Task 1)
  paperforge/worker/_utils.py (pipeline_paths, slugify_filename)
  tests/test_sync.py (existing test patterns for mocking, fixture setup)
</read_first>

<action>
Create `tests/test_migration.py` with the following test cases covering all 15 decisions (D-11 through D-15):

Use `pytest` and the existing test fixture patterns from `tests/test_sync.py` (mock vault structure with `Literature/<domain>/` dir, fake canonical index, etc.). Use `tmp_path` for vault root. Use `monkeypatch` where needed for path resolution.

**Test 1 — `test_migrate_flat_note_to_workspace` (D-11, D-12):**
- Create a mock vault with a flat note at `Literature/骨科/KEY123-title.md`
- Create a mock canonical index with one entry referencing that flat note
- Call `migrate_to_workspace(vault, paths)`
- Assert: workspace dir `Literature/骨科/KEY123-title/` is created
- Assert: main note at `Literature/骨科/KEY123-title/KEY123-title.md` has same content as flat note
- Assert: flat note `Literature/骨科/KEY123-title.md` still exists (D-12: copy-not-move)

**Test 2 — `test_migrate_extracts_deep_reading` (D-13):**
- Create a flat note that contains a `## 🔍 精读` section with substantive content
- Call migrate_to_workspace
- Assert: `deep-reading.md` exists in workspace dir
- Assert: `deep-reading.md` content starts with `## 🔍 精读` and contains the deep reading content
- Assert: main note in workspace also contains the `## 🔍 精读` section (it's a complete copy)

**Test 3 — `test_migrate_creates_ai_dir`:**
- Call migrate_to_workspace on a migrated paper
- Assert: `ai/` directory exists in workspace dir
- Assert: `ai/` directory is empty

**Test 4 — `test_migrate_idempotent_skips_existing` (D-15):**
- Create a paper whose workspace dir already exists (previously migrated)
- Call migrate_to_workspace
- Assert: return count is 0 (nothing new migrated)
- Assert: workspace content is not overwritten (use a content marker to verify)

**Test 5 — `test_build_entry_writes_to_workspace_after_migration`:**
- Create a mock vault with workspace dir already existing
- Call `_build_entry()` with a mock export item
- Assert: the note is written to `main_note_path` (workspace), not the flat path
- Assert: the written note has correct frontmatter

**Test 6 — `test_build_entry_flat_fallback_for_unmigrated_paper`:**
- Create a mock vault with NO workspace dir but with a flat note
- Call `_build_entry()` with a mock export item
- Assert: the note is written to the flat path (backward compat)
- Assert: workspace dir is NOT created

**Test 7 — `test_build_entry_new_paper_creates_workspace`:**
- Create a mock vault with neither flat note nor workspace dir (brand new paper)
- Call `_build_entry()` with a mock export item
- Assert: workspace dir is created
- Assert: note is written to workspace `main_note_path`
- Assert: `ai/` dir is created in workspace

**Test 8 — `test_run_index_refresh_calls_migrate`:**
- Monkey-patch `migrate_to_workspace` with a spy
- Call `run_index_refresh()`
- Assert: `migrate_to_workspace` was called at least once

Use `pytest.mark.parametrize` where appropriate. All tests must be fast (< 1s each) — no network or filesystem-side-effect dependencies.
</action>

<acceptance_criteria>
- `tests/test_migration.py` exists with 8 test cases
- All 8 tests pass: `pytest tests/test_migration.py -x -q --tb=short`
- Each test covers a specific D-11 through D-15 scenario
- Tests use `tmp_path` and monkeypatch, no external state
</acceptance_criteria>

<verify>
  <automated>pytest tests/test_migration.py -x -q --tb=short</automated>
</verify>

<done>
All 8 migration tests pass. Flat-to-workspace migration, _build_entry workspace writing, idempotency, and backward compatibility are verified.
</done>
</task>

</tasks>

<verification>
1. `pytest tests/test_migration.py -x -q --tb=short` — all 8 tests pass
2. `python -c "from paperforge.worker.sync import migrate_to_workspace; print('OK')"` — importable
3. Manual scan: `Literature/<domain>/` should now contain both flat notes AND workspace dirs after sync
4. Existing tests pass: `pytest tests/test_sync.py -x -q --tb=short` (no regressions)
</verification>

<success_criteria>
- D-11: Existing flat notes migrated to workspace on first sync
- D-12: Original flat note preserved (copy-not-move)
- D-13: `## 🔍 精读` section extracted to `deep-reading.md`
- D-14: Workspace paths in canonical index point to real files  
- D-15: Migration is idempotent, already-migrated papers skipped
- ai/ directory created in each paper workspace
- _build_entry writes to workspace path when workspace exists, falls back to flat for unmigrated
</success_criteria>

<output>
After completion, create `.planning/phases/26-traceable-ai-context-packs/26-01-SUMMARY.md`
</output>
