---
phase: 22-configuration-truth-compatibility
plan: 03
type: execute
wave: 2
depends_on:
  - 22-01
files_modified:
  - paperforge/setup_wizard.py
  - paperforge/worker/status.py
  - paperforge/config.py
autonomous: true
requirements:
  - CONF-02
  - CONF-03

must_haves:
  truths:
    - "Setup wizard writes paperforge.json with only vault_config block (no duplicate top-level path keys)"
    - "Setup wizard output includes schema_version: 2"
    - "paperforge doctor detects stale top-level path keys and reports them as a warning"
    - "paperforge doctor shows the backup path in its migration warning"
    - "User can inspect the effective runtime config and see which source resolved each value (CONF-03)"
  artifacts:
    - path: "paperforge/setup_wizard.py"
      provides: "Clean vault_config-only paperforge.json output"
      contains: "vault_config"
    - path: "paperforge/worker/status.py"
      provides: "Doctor migration detection + config source tracing"
      contains: "stale top-level"
    - path: "paperforge/config.py"
      provides: "Config source tracking in load_vault_config()"
      contains: "config_source"
  key_links:
    - from: "setup_wizard.py (line ~2013)"
      to: "paperforge.json vault_config block"
      via: "writes only vault_config keys, no top-level path duplicates"
    - from: "status.py :: run_doctor()"
      to: "config.py :: read_paperforge_json()"
      via: "detects top-level path keys"

---

<objective>
Ensure the setup wizard writes clean `vault_config`-only `paperforge.json` (no duplicate top-level path keys) with `schema_version`. Extend `paperforge doctor` to detect stale legacy config. Add config source tracing for CONF-03 inspection.

Purpose: New installs start with the canonical format. Brownfield users can detect migration status. Users can see which config source resolved each path value (D-11, D-12, D-13, CONF-02, CONF-03).

Output: `setup_wizard.py` clean output + `status.py` doctor migration detection + `config.py` config source tracing.
</objective>

<execution_context>
@C:/Users/Lin/.opencode/get-shit-done/workflows/execute-plan.md
</execution_context>

<context>
@.planning/phases/22-configuration-truth-compatibility/22-CONTEXT.md (D-06..D-13)
@paperforge/setup_wizard.py (lines 2013-2038 -- paperforge.json write block)
@paperforge/worker/status.py (lines 191-382 -- run_doctor function)
@paperforge/config.py (lines 149-205 -- load_vault_config merge logic)
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Setup wizard writes clean vault_config-only paperforge.json with schema_version</name>
  <files>paperforge/setup_wizard.py</files>

  <read_first>
  @paperforge/setup_wizard.py (lines 2013-2038 -- paperforge.json write block)
  @paperforge/setup_wizard.py (lines 1826-1835 -- built_vault_config construction)
  </read_first>

  <behavior>
  - The paperforge.json write block (line 2013-2038) currently writes BOTH top-level keys (system_dir, resources_dir, etc.) AND a vault_config block
  - After this change: only the vault_config block is written for path keys. Top-level keys like `version`, `agent_platform`, `agent_key`, `skill_dir`, `command_dir`, `paperforge_path`, `zotero_data_dir`, `zotero_link` are still written at top level (those are NOT path keys)
  - The top-level path keys `system_dir`, `resources_dir`, `literature_dir`, `control_dir`, `base_dir` are REMOVED from the write block
  - `schema_version: "2"` is added to the output
  - The `built_vault_config` dict (line 1829-1835) stays as is -- it already has the correct path keys
  - Existing paperforge.json merge logic (`existing_config.update(...)`) is replaced with a clean build to avoid accumulating stale top-level keys
  </behavior>

  <action>
  In `paperforge/setup_wizard.py`, replace the paperforge.json write block (lines 2013-2038).

  Current code (lines 2013-2038):
  ```python
      # paperforge.json
      pf_json = vault / "paperforge.json"
      existing_config = {}
      if pf_json.exists():
          try:
              existing_config = json.loads(pf_json.read_text(encoding="utf-8"))
          except Exception:
              existing_config = {}
      existing_config.update({
          "version": existing_config.get("version", "1.4.3"),
          "agent_platform": agent_config.get("name", "OpenCode"),
          "agent_key": agent_key,
          "skill_dir": skill_dir,
          "command_dir": agent_config.get("command_dir", ""),
          "system_dir": system_dir,
          "resources_dir": resources_dir,
          "literature_dir": literature_dir,
          "control_dir": control_dir,
          "base_dir": base_dir,
          "paperforge_path": f"{system_dir}/PaperForge",
          "zotero_data_dir": zotero_data or "",
          "zotero_link": f"{system_dir}/Zotero",
          "vault_config": built_vault_config,
      })
      pf_json.write_text(json.dumps(existing_config, indent=2, ensure_ascii=False), encoding="utf-8")
      print(f"    [OK] paperforge.json")
  ```

  Replace with:
  ```python
      # paperforge.json -- canonical format: path keys only in vault_config block
      pf_json = vault / "paperforge.json"
      existing_config = {}
      if pf_json.exists():
          try:
              existing_config = json.loads(pf_json.read_text(encoding="utf-8"))
          except Exception:
              existing_config = {}
      config = {
          "version": existing_config.get("version", "1.4.3"),
          "schema_version": "2",
          "agent_platform": agent_config.get("name", "OpenCode"),
          "agent_key": agent_key,
          "skill_dir": skill_dir,
          "command_dir": agent_config.get("command_dir", ""),
          "paperforge_path": f"{system_dir}/PaperForge",
          "zotero_data_dir": zotero_data or "",
          "zotero_link": f"{system_dir}/Zotero",
          "vault_config": built_vault_config,
      }
      pf_json.write_text(json.dumps(config, indent=2, ensure_ascii=False), encoding="utf-8")
      print(f"    [OK] paperforge.json")
  ```

  Key differences:
  - Removed `system_dir`, `resources_dir`, `literature_dir`, `control_dir`, `base_dir` from top level
  - Added `"schema_version": "2"`
  - `vault_config` block unchanged (already correct)
  - `existing_config.update(...)` replaced with explicit dict (no accumulation of stale top-level keys from old file)
  - Preserved: version, agent_platform, agent_key, skill_dir, command_dir, paperforge_path, zotero_data_dir, zotero_link
  </action>

  <verify>
  <automated>python -c "
import json
# Parse the setup_wizard.py file and verify no top-level path keys in the write block
with open('paperforge/setup_wizard.py', 'r') as f:
    content = f.read()

# Check that the vault_config write block exists
assert '\"vault_config\": built_vault_config' in content, 'vault_config write must exist'
print('OK: vault_config block present')

# Check that top-level path keys are NOT written
for key in ['\"system_dir\": system_dir', '\"resources_dir\": resources_dir',
            '\"literature_dir\": literature_dir', '\"control_dir\": control_dir',
            '\"base_dir\": base_dir']:
    # These should NOT appear in the json.dumps() call block
    # Find the json.dumps call for paperforge.json
    idx = content.find('paperforge.json -- canonical format')
    block = content[idx:idx+2000] if idx >= 0 else content[-2000:]
    if key in block:
        # Only fail if it's in the json.dumps block, not in built_vault_config construction
        dumps_idx = block.find('json.dumps')
        if dumps_idx >= 0 and key in block[dumps_idx:]:
            print(f'WARN: {key} still in json.dumps block')
            # Check if it's inside vault_config (that's OK)
            vc_end = block.find('}', block.find('\"vault_config\"'))
            key_idx = block.find(key, dumps_idx)
            if key_idx > dumps_idx and (vc_end < 0 or key_idx > vc_end):
                print(f'FAIL: {key} is outside vault_config in the write block')
                exit(1)

print('OK: no top-level path keys outside vault_config')

# Check schema_version in write block
assert '\"schema_version\": \"2\"' in block, 'schema_version must be in write block'
print('OK: schema_version present')

print('ALL CHECKS PASSED')
"</automated>
  </verify>

  <acceptance_criteria>
  - `paperforge.json` write block does NOT contain top-level keys `system_dir`, `resources_dir`, `literature_dir`, `control_dir`, `base_dir` in the json.dumps output
  - Write block contains `"schema_version": "2"`
  - Write block still contains `"vault_config": built_vault_config`
  - Non-path top-level keys (version, agent_platform, agent_key, skill_dir, command_dir, paperforge_path, zotero_data_dir, zotero_link) are still written at top level
  - Python verify script passes all checks
  </acceptance_criteria>

  <done>
  Setup wizard writes clean vault_config-only paperforge.json with schema_version. No duplicate top-level path keys.
  </done>
</task>

<task type="auto">
  <name>Task 2: Extend paperforge doctor to detect stale top-level config and show migration status</name>
  <files>paperforge/worker/status.py</files>

  <read_first>
  @paperforge/worker/status.py (lines 191-382 -- run_doctor function, especially the checks section)
  @paperforge/config.py (read_paperforge_json, CONFIG_PATH_KEYS constants)
  @paperforge/config.py (migrate_paperforge_json)
  </read_first>

  <action>
  In `paperforge/worker/status.py`, extend `run_doctor()` to detect stale top-level config and report migration status:

  1. Add import at the top of the file (after existing `from paperforge.config import load_vault_config, paperforge_paths` on line 11):
     ```python
     from paperforge.config import (
         load_vault_config,
         paperforge_paths,
         read_paperforge_json,
         CONFIG_PATH_KEYS,
         get_paperforge_schema_version,
     )
     ```

  2. Add a new doctor check block AFTER the existing "BBT export" checks (around line 302, after `add_check("BBT 导出", ...)`) and BEFORE the "OCR config" section (line 304):

     ```python
         # Config Migration check
         pf_data = read_paperforge_json(vault)
         if pf_data:
             has_stale_top_level = any(k in pf_data for k in CONFIG_PATH_KEYS)
             if has_stale_top_level:
                 backup_path = vault / "paperforge.json.bak"
                 backup_hint = f" (backup: {backup_path})" if backup_path.exists() else ""
                 add_check(
                     "Config Migration",
                     "warn",
                     f"paperforge.json has stale top-level path keys -- run `paperforge sync` to auto-migrate{backup_hint}",
                     "Run `paperforge sync` to auto-migrate to vault_config canonical format",
                 )
             else:
                 add_check(
                     "Config Migration",
                     "pass",
                     "paperforge.json uses canonical vault_config format",
                 )

             # Schema version check
             sv = get_paperforge_schema_version(vault)
             if sv >= 2:
                 add_check("Config Migration", "pass", f"schema_version: {sv}")
             else:
                 add_check(
                     "Config Migration",
                     "info",
                     f"schema_version: {sv} (migration available via `paperforge sync`)",
                 )
         else:
             add_check("Config Migration", "info", "paperforge.json not found -- new install?")
     ```

  3. Also update the existing `paperforge.json` check (line 232-235) to be part of "Vault Structure" (it already is) -- no change needed there:
     ```python
         if (vault / "paperforge.json").exists():
             add_check("Vault 结构", "pass", "paperforge.json 存在")
     ```
     This stays as-is.

  Note: The CONFIG_PATH_KEYS constant was defined in Plan 01 Task 2 at the end of config.py. It is a tuple of the 5 path keys: system_dir, resources_dir, literature_dir, control_dir, base_dir.
  </action>

  <verify>
  <automated>python -c "
# Verify the imports and function references in status.py
with open('paperforge/worker/status.py', 'r') as f:
    content = f.read()

# Check imports
assert 'read_paperforge_json' in content, 'must import read_paperforge_json'
assert 'CONFIG_PATH_KEYS' in content, 'must import CONFIG_PATH_KEYS'
assert 'get_paperforge_schema_version' in content, 'must import get_paperforge_schema_version'
print('OK: all imports present')

# Check doctor has the migration check
assert 'Config Migration' in content, 'doctor must have Config Migration category'
print('OK: Config Migration check category present')

# Check stale top-level detection
assert 'stale top-level' in content, 'must detect stale top-level keys'
print('OK: stale top-level detection present')

# Check schema version display
assert 'schema_version' in content, 'must display schema_version'
print('OK: schema_version display present')

print('ALL CHECKS PASSED')
"</automated>
  </verify>

  <acceptance_criteria>
  - `status.py` imports `read_paperforge_json`, `CONFIG_PATH_KEYS`, `get_paperforge_schema_version` from `paperforge.config`
  - `run_doctor()` has a "Config Migration" section in doctor output
  - Doctor detects stale top-level path keys and emits a WARN with remediation hint
  - Doctor shows the backup path in the warning if `paperforge.json.bak` exists
  - Doctor displays current `schema_version` (pass if >= 2, info otherwise)
  - Doctor handles missing paperforge.json gracefully (info, not fail)
  - Python verify script passes all checks
  </acceptance_criteria>

  <done>
  paperforge doctor now detects stale top-level config, shows migration status, and displays schema_version. Users can inspect config health.
  </done>
</task>

<task type="auto">
  <name>Task 3: Add config source tracing to load_vault_config for CONF-03 runtime inspection</name>
  <files>paperforge/config.py</files>

  <read_first>
  @paperforge/config.py (lines 149-205 -- load_vault_config merge logic)
  @paperforge/config.py (lines 79-95 -- read_paperforge_json)
  </read_first>

  <action>
  In `paperforge/config.py`, add optional config source tracing to `load_vault_config()`:

  1. Add a new parameter to `load_vault_config()` (line 149):
     Change the function signature from:
     ```python
     def load_vault_config(
         vault: Path,
         env: dict[str, str] | None = None,
         overrides: dict[str, str] | None = None,
     ) -> dict[str, str]:
     ```
     To:
     ```python
     def load_vault_config(
         vault: Path,
         env: dict[str, str] | None = None,
         overrides: dict[str, str] | None = None,
         trace_sources: bool = False,
     ) -> dict[str, str] | tuple[dict[str, str], dict[str, str]]:
     ```
     (The return type annotation is approximate -- the actual behavior is documented.)

  2. Add source tracking INSIDE the function. After the function body starts (line 172 `env = env if env is not None else os.environ`), add:
     ```python
         trace = {}  # key -> source name
     ```

  3. After each merge step, record the source for each key that gets set:

     After step 2 (vault_config merge, lines 180-185):
     ```python
         for key in CONFIG_KEYS:
             if key in nested and nested[key]:
                 config[key] = nested[key]
                 if trace_sources:
                     trace[key] = "vault_config"
     ```

     After step 3 (top-level legacy keys, lines 187-190):
     ```python
         for key in CONFIG_KEYS:
             if key in pf_data and pf_data[key]:
                 config[key] = pf_data[key]
                 if trace_sources:
                     trace[key] = "top_level"
     ```

     After step 4 (env vars, lines 192-197):
     ```python
         for config_key, env_var in ENV_KEYS.items():
             if config_key == "vault":
                 continue
             if env_var in env and env[env_var]:
                 config[config_key] = env[env_var]
                 if trace_sources:
                     trace[config_key] = "env"
     ```

     After step 5 (explicit overrides, lines 199-203):
     ```python
         if overrides:
             for key in CONFIG_KEYS:
                 if key in overrides and overrides[key]:
                     config[key] = overrides[key]
                     if trace_sources:
                         trace[key] = "override"
     ```

  4. At the end of the function (before `return config`), add:
     ```python
         if trace_sources:
             return config, trace
         return config
     ```

  5. Update the function's return type annotation to:
     ```python
     ) -> dict[str, str] | tuple[dict[str, str], dict[str, str]]:
     ```

  6. Update the docstring to document the new parameter:
     ```
         trace_sources: If True, returns (config_dict, source_trace) where
                       source_trace maps each key to its winning source
                       ("default", "vault_config", "top_level", "env", "override").
     ```

  7. Important: The `config.pop("schema_version", None)` from Plan 01 Task 1 must happen AFTER the tracing (or the trace should also exclude schema_version). The pop happens before the return, so schema_version won't appear in config. The trace won't have a "schema_version" entry since CONFIG_KEYS includes it but none of the merge steps actually set it (except the initial defaults, which we popped). This is correct behavior.

  8. For keys that stay at their default value (never overridden by any source), they will not have a trace entry. This is acceptable -- their source is implicitly "default".
  </action>

  <verify>
  <automated>python -c "
from paperforge.config import load_vault_config
from pathlib import Path
import tempfile

d = tempfile.mkdtemp()
Path(d, 'paperforge.json').write_text('{}')

# Test without trace (backward compat)
cfg = load_vault_config(Path(d))
assert isinstance(cfg, dict)
print('OK: backward compat works')

# Test with trace
result = load_vault_config(Path(d), trace_sources=True)
assert isinstance(result, tuple) and len(result) == 2
cfg, trace = result
assert isinstance(trace, dict)
# All keys should be from 'default' or not in trace at all
for key in trace:
    assert trace[key] in ('default', 'vault_config', 'top_level', 'env', 'override'), f'Unknown source: {trace[key]}'
print('OK: trace sources works')

# Test with vault_config taking effect
import json
d2 = tempfile.mkdtemp()
Path(d2, 'paperforge.json').write_text(json.dumps({
    'vault_config': {'system_dir': 'CustomSystem'}
}))
cfg2, trace2 = load_vault_config(Path(d2), trace_sources=True)
assert trace2.get('system_dir') == 'vault_config', f'Expected vault_config, got {trace2.get(\"system_dir\")}'
print('OK: vault_config source tracked correctly')

print('ALL CHECKS PASSED')
"</automated>
  </verify>

  <acceptance_criteria>
  - `load_vault_config()` accepts a new `trace_sources: bool = False` parameter
  - When `trace_sources=False` (default), behavior is identical to current (returns dict)
  - When `trace_sources=True`, returns `(config_dict, source_dict)` tuple
  - Source dict maps each config key to its winning source: `"default"`, `"vault_config"`, `"top_level"`, `"env"`, `"override"`
  - Docstring is updated to document the new parameter
  - Backward compatible: existing callers (CLI, sync, status, etc.) pass no trace_sources arg and get the same return type
  - Python verify script passes all checks
  </acceptance_criteria>

  <done>
  load_vault_config() supports optional config source tracing. When trace_sources=True, returns (config, trace) showing which source resolved each value. Backward compatible.
  </done>
</task>

</tasks>

<verification>

### Plan 03 Verification

1. **Setup wizard clean output:** paperforge.json block writes vault_config only, no top-level path keys, includes schema_version
2. **Doctor migration detection:** `run_doctor()` has "Config Migration" category, detects stale top-level keys, shows backup path, displays schema_version
3. **Config source tracing:** `load_vault_config(trace_sources=True)` returns (config, source_trace) without breaking existing callers
4. **End-to-end:** New install via setup wizard creates clean paperforge.json. Doctor on brownfield vault detects stale config. Source tracing works.
</verification>

<success_criteria>

- [ ] Setup wizard writes `"schema_version": "2"` and `vault_config` block with no duplicate top-level path keys
- [ ] `paperforge doctor` has "Config Migration" section that detects stale top-level config
- [ ] Doctor migration warning includes backup path hint
- [ ] Doctor displays schema_version
- [ ] `load_vault_config(trace_sources=True)` returns `(config, trace)` tuple
- [ ] Trace values are one of: default, vault_config, top_level, env, override
- [ ] All existing callers of `load_vault_config()` continue to work unchanged (backward compat)
- [ ] All automated verify commands pass
</success_criteria>

<output>
After completion, create `.planning/phases/22-configuration-truth-compatibility/22-03-SUMMARY.md`
</output>
