---
phase: 35-ai-discussion-recorder
plan: "01"
type: execute
wave: 1
depends_on: []
files_modified:
  - paperforge/worker/discussion.py
  - tests/test_discussion.py
  - paperforge/skills/literature-qa/scripts/pf-paper.md
autonomous: true
requirements:
  - AI-01
  - AI-02
  - AI-03

must_haves:
  truths:
    - "Importing and calling record_session() creates ai/discussion.json containing schema_version: '1', paper_key, and sessions[] array"
    - "Calling record_session() creates ai/discussion.md with human-readable 问题:/解答: format and chronological ## session headings"
    - "Re-running record_session() for the same paper appends a new session to both files — never overwrites previous discussions"
    - "Both files are written atomically via tempfile.NamedTemporaryFile + os.replace() — partial writes don't corrupt"
    - "discussion.md uses explicit utf-8 encoding and ensure_ascii=False — Chinese content survives without mojibake"
    - "Module uses Python stdlib only (json, pathlib, datetime, tempfile, os, uuid, argparse) — zero new dependencies"
    - "CLI subcommand works: python -m paperforge.worker.discussion record <key> --vault <path> --agent pf-paper --model <model>"
    - "/pf-paper prompt instructs agent to accumulate Q&A pairs during session and call CLI at session end"
  artifacts:
    - path: "paperforge/worker/discussion.py"
      provides: "record_session() function and CLI subcommand for discussion recording"
      min_lines: 150
    - path: "tests/test_discussion.py"
      provides: "Unit tests for record_session() behavior"
      min_lines: 80
    - path: "paperforge/skills/literature-qa/scripts/pf-paper.md"
      provides: "Updated /pf-paper prompt with 保存讨论记录 step"
      contains: "保存讨论记录"
  key_links:
    - from: "paperforge/worker/discussion.py"
      to: "paperforge/config.py"
      via: "paperforge_paths() for path resolution"
      pattern: "from paperforge\.config import"
    - from: "paperforge/worker/discussion.py"
      to: "paperforge/worker/_utils.py"
      via: "slugify_filename() for title slugging, scan_library_records() for paper lookup"
      pattern: "from paperforge\.worker\._utils import"
    - from: "paperforge/skills/literature-qa/scripts/pf-paper.md"
      to: "paperforge/worker/discussion.py"
      via: "CLI invocation at session end"
      pattern: "paperforge\.worker\.discussion record"
---

<objective>
Create `paperforge/worker/discussion.py` — a stdlib-only Python module that records AI-paper discussion sessions into `ai/discussion.json` (canonical, append-only) and `ai/discussion.md` (human-readable) with atomic writes. Wire it into the `/pf-paper` agent prompt so agent sessions automatically save discussion records at completion.

Purpose: Phase 35 delivers the AI Discussion Recorder — the Python-side infrastructure that captures structured Q&A records from agent sessions into each paper's workspace `ai/` directory. This enables Phase 36's dashboard to render AI Q&A history.

Output:
- `paperforge/worker/discussion.py` — importable `record_session()` function + CLI subcommand
- `tests/test_discussion.py` — unit tests covering record, append, atomic write, error paths
- `paperforge/skills/literature-qa/scripts/pf-paper.md` — updated prompt with recording step

**Design decisions (from CONTEXT.md, per /gsd-discuss-phase):**
- D-05: Only `/pf-paper` records discussions. `/pf-deep` does NOT record (output already in formal note). This OVERRIDES the AI-03 requirement wording which mentions both — implementation follows D-05.
- D-08: Both files written independently from same data object.
- D-09: Atomic write via `tempfile.NamedTemporaryFile` + `os.replace()`.
- D-10: Append-only — read existing, append session, write back.
- D-04: stdlib only (`json`, `pathlib`, `datetime`, `tempfile`, `os`, `uuid`, `argparse`).
</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/ROADMAP.md § Phase 35
@.planning/REQUIREMENTS.md § AI-01, AI-02, AI-03
@.planning/phases/35-ai-discussion-recorder/35-CONTEXT.md
@.planning/STATE.md
@.planning/PROJECT.md

<interfaces>
<!-- Key types and contracts the executor needs. From existing codebase. -->

From paperforge/config.py:
```python
def paperforge_paths(vault: Path, cfg: dict | None = None) -> dict[str, Path]:
    """Returns keys: vault, system, paperforge, exports, ocr, resources,
       literature, control, library_records, bases, skill_dir, ld_deep_script"""
```

From paperforge/worker/_utils.py:
```python
def slugify_filename(text: str) -> str:
    """Sanitize title for file path — strips <>:"/\\|?*, max 120 chars"""

def read_json(path: Path):
    """json.loads(path.read_text(encoding='utf-8'))"""

def write_json(path: Path, data) -> None:
    """path.parent.mkdir(parents=True, exist_ok=True)
       path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding='utf-8')"""

def scan_library_records(vault: Path) -> list[dict]:
    """Returns list of dicts with zotero_key, domain, title, analyze, etc."""
```

From paperforge/worker/asset_index.py (line 307):
```python
"ai_path": f"Literature/{domain}/{key} - {title_slug}/ai/"
```

From tests/conftest.py:
```python
REPO_ROOT = Path(__file__).resolve().parent.parent
SANDBOX_DIR = REPO_ROOT / "tests" / "sandbox"
FIXTURE_VAULT = SANDBOX_DIR / "00_TestVault"
```

From paperforge/skills/literature-qa/scripts/pf-paper.md:
The current prompt ends after "## See Also" section. The new recording step goes before "## See Also" as step 8 in the workflow.
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Create paperforge/worker/discussion.py with record_session() and CLI</name>
  <files>paperforge/worker/discussion.py, tests/test_discussion.py</files>

  <read_first>
  - .planning/phases/35-ai-discussion-recorder/35-CONTEXT.md (locked decisions D-01 through D-17)
  - paperforge/worker/_utils.py (slugify_filename, write_json, read_json, scan_library_records patterns)
  - paperforge/config.py (paperforge_paths, load_vault_config)
  - paperforge/worker/asset_index.py:295-308 (ai_path construction pattern)
  - tests/conftest.py (test vault structure, fixture patterns)
  - tests/test_utils_json.py:1-50 (test class pattern)
  </read_first>

  <behavior>
    - Test 1: record_session() with valid inputs creates ai/discussion.json containing sessions[0] with session_id (UUID string, 36 chars), agent="pf-paper", model string, started (ISO 8601 with timezone), paper_key, paper_title, domain, and qa_pairs[] where each pair has question, answer, source (one of "user_question"|"agent_analysis"), and timestamp
    - Test 2: record_session() creates ai/discussion.md with "# AI Discussion Record: {title}" header, "## {date} — {agent} ({model})" session heading, "**问题:**" / "**解答:**" pairs, and "---" separator
    - Test 3: Calling record_session() twice for same paper appends second session to sessions[] in JSON (length=2) and adds second ## session heading in MD — first session data preserved intact
    - Test 4: record_session() with non-existent vault returns {"status": "error", ...}
    - Test 5: record_session() with unknown zotero_key returns {"status": "error", ...} without creating files
    - Test 6: Written JSON contains ensure_ascii=False Chinese characters (e.g., "问题":"中文测试") parsed correctly by json.load()
    - Test 7: CLI `python -m paperforge.worker.discussion record <key> --vault <path> --agent pf-paper --model gpt-4 --qa-pairs '[{"question":"Q1","answer":"A1","source":"user_question","timestamp":"2026-05-06T12:00:00+08:00"}]'` exits 0 and creates discussion.json
  </behavior>

  <action>
Create `paperforge/worker/discussion.py` with the following implementation:

### Module structure
```python
"""Discussion recorder — writes structured AI-paper Q&A into ai/ workspace directory.

Atomic append-only writes for both JSON (canonical) and Markdown (human-readable).
stdlib only — no dependencies beyond Python standard library.

API:
    record_session(vault_path, zotero_key, agent, model, qa_pairs) -> dict

CLI:
    python -m paperforge.worker.discussion record <zotero_key> --vault <path>
        --agent <name> --model <model> [--qa-pairs <json>]
"""
from __future__ import annotations

import argparse
import json
import os
import tempfile
import uuid
from datetime import datetime, timezone, timedelta
from pathlib import Path
```

### record_session() function (per D-01, D-02)

Signature:
```python
def record_session(
    vault_path: Path,
    zotero_key: str,
    agent: str,
    model: str,
    qa_pairs: list[dict],
) -> dict:
```

Returns: `{"status": "ok"/"error", "json_path": <str>, "md_path": <str>}`
On error: `{"status": "error", "message": <str>}` — never raises.

Implementation steps:
1. Validate inputs: vault_path exists/is_dir, zotero_key is non-empty str, qa_pairs is list
2. Load config: `from paperforge.config import paperforge_paths`, get path inventory
3. Find paper metadata: scan `{library_records}/*/{zotero_key}.md` for domain and title fields
   - If library-records path is `<control>/library-records`, iterate domain dirs
   - Read record frontmatter: regex `^domain:\s*(.+)$`, `^title:\s*"?(.+?)"?$`
   - If not found: fallback scan all *.md in subdirs by zotero_key frontmatter
   - If still not found: return error dict
4. Construct ai_path: `{literature}/{domain}/{key} - {title_slug}/ai/`
   - Use `slugify_filename(title)` from `paperforge.worker._utils` (per existing codebase pattern)
5. Read existing discussion.json if it exists (handle FileNotFoundError, JSONDecodeError gracefully — start fresh)
6. Build session object (per D-11, D-12):
   ```python
   session = {
       "session_id": str(uuid.uuid4()),
       "agent": agent,  # "pf-paper" per D-05
       "model": model,
       "started": datetime.now(timezone(timedelta(hours=8))).isoformat(),
       "paper_key": zotero_key,
       "paper_title": paper_title,
       "domain": domain,
       "qa_pairs": qa_pairs,  # Each: {question, answer, source, timestamp}
   }
   ```
7. Atomic write discussion.json (per D-09, D-10):
   - Build envelope: `{"schema_version": "1", "paper_key": zotero_key, "sessions": [...existing..., session]}`
   - `json.dumps(envelope, ensure_ascii=False, indent=2)` (per D-16)
   - Write to `tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', suffix='.json', dir=ai_dir, delete=False)`
   - Write content with `newline='\n'` (use `file.write()` with `\n` line endings)
   - `os.replace(temp_path, json_target_path)` — atomic on Windows
   - On any failure: cleanup temp file, return error

8. Build and atomic write discussion.md (per D-13):
   - Format as:
     ```
     # AI Discussion Record: {paper_title}

     ## {date_str} — {agent} ({model})

     **问题:** {question_1}
     **解答:** {answer_1}

     **问题:** {question_2}
     **解答:** {answer_2}

     ---
     ```
   - date_str = YYYY-MM-DD from session started timestamp
   - Read existing MD content (if file exists), append new session after a blank line
   - If file doesn't exist, create with header
   - Same atomic write pattern: tempfile + os.replace()

### CLI subcommand (per D-03)

Add `if __name__ == "__main__":` block using argparse:
```python
def _build_cli_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="python -m paperforge.worker.discussion",
        description="Record AI-paper discussion sessions"
    )
    sub = parser.add_subparsers(dest="command", required=True)
    record_p = sub.add_parser("record", help="Record a discussion session")
    record_p.add_argument("zotero_key", help="Zotero citation key")
    record_p.add_argument("--vault", required=True, help="Path to Obsidian vault")
    record_p.add_argument("--agent", required=True, help="Agent name (e.g. pf-paper)")
    record_p.add_argument("--model", required=True, help="Model identifier")
    record_p.add_argument("--qa-pairs", default="[]", help="JSON array of Q&A pairs")
    return parser
```

CLI main: parse args, resolve vault path, parse qa_pairs JSON, call record_session(), print result JSON to stdout, exit 0 on success / 1 on error.

### Error handling (per D-17)
- Never crash — always return status dict
- Catch FileNotFoundError, JSONDecodeError, OSError, PermissionError
- Temp file cleanup in finally block
- Log warnings via `logging.getLogger(__name__).warning()` for non-fatal issues

### Test file: tests/test_discussion.py
Create following existing test patterns (class-based, tmp_path fixture, see tests/test_utils_json.py):
- `class TestRecordSession`: test_create_both_files, test_append_second_session, test_missing_vault, test_unknown_key, test_cjk_encoding, test_atomic_write_no_partial
- Use `create_test_vault()` from conftest or tmp_path with minimal directory structure
- For library-record lookup tests: create `{tmp}/03_Resources/LiteratureControl/library-records/{domain}/{key}.md` with yaml frontmatter containing `zotero_key`, `domain`, `title` fields
</action>

  <verify>
    <automated>pytest tests/test_discussion.py -v --tb=short</automated>
  </verify>

  <acceptance_criteria>
    - `paperforge/worker/discussion.py` exists with `record_session` function exported
    - `from paperforge.worker.discussion import record_session` succeeds without ImportError
    - All 7 behavior tests pass (pytest exits 0)
    - `python -m paperforge.worker.discussion --help` prints usage without error
    - `grep "schema_version.*1" paperforge/worker/discussion.py` finds match
    - `grep "os\.replace" paperforge/worker/discussion.py` finds at least 2 matches (one per file)
    - `grep "NamedTemporaryFile" paperforge/worker/discussion.py` finds match
    - `grep "ensure_ascii=False" paperforge/worker/discussion.py` finds match
    - Module imports only stdlib + paperforge internal modules (no pip-installed third-party packages)
  </acceptance_criteria>

  <done>discussion.py module is importable, record_session() passes all tests, CLI subcommand works</done>
</task>

<task type="auto">
  <name>Task 2: Update pf-paper.md prompt with discussion recording step</name>
  <files>paperforge/skills/literature-qa/scripts/pf-paper.md</files>

  <read_first>
  - paperforge/skills/literature-qa/scripts/pf-paper.md (current complete content)
  - .planning/phases/35-ai-discussion-recorder/35-CONTEXT.md (D-05: only pf-paper records, D-06: update prompt)
  - paperforge/worker/discussion.py (CLI syntax to reference — from Task 1 output)
  </read_first>

  <action>
Update `paperforge/skills/literature-qa/scripts/pf-paper.md` to add discussion recording step.

### Change 1: Add step 8 to workflow (after step 7, before "## See Also")

Insert the following section between the "Q&A模式" instructions (step 7 area) and the "## See Also" section:

```markdown
### 8. 保存讨论记录

在 Q&A 会话结束时，Agent 必须将本次讨论记录保存到论文工作区的 `ai/` 目录中。

**操作步骤：**

1. 在会话中积累所有 Q&A 对 —— 每次用户提问和 Agent 回答都记录为一对：
   ```json
   {
     "question": "用户的问题",
     "answer": "Agent 的回答",
     "source": "user_question",
     "timestamp": "2026-05-06T12:00:00+08:00"
   }
   ```
   其中 `source` 为 `"user_question"`（用户提问）或 `"agent_analysis"`（Agent 主动分析）。

2. 会话结束时，将 Q&A 对序列化为 JSON 字符串，调用 CLI：
   ```bash
   python -m paperforge.worker.discussion record <ZOTERO_KEY> \
       --vault "<VAULT_PATH>" \
       --agent pf-paper \
       --model "<MODEL_NAME>" \
       --qa-pairs '<JSON_ARRAY>'
   ```

3. CLI 返回 JSON：
   ```json
   {"status": "ok", "json_path": "Literature/{domain}/{key} - {title}/ai/discussion.json", "md_path": "Literature/{domain}/{key} - {title}/ai/discussion.md"}
   ```
   如果 `status` 为 `"error"`，记录错误信息但不中断会话流程。

**注意：**
- 仅 `/pf-paper` 记录讨论。`/pf-deep` 不记录（精读内容已写入正式笔记）。
- 如果论文没有 library-record（无法解析 domain/title），记录会失败但不影响正常使用。
- 所有 Q&A 内容以 UTF-8 编码写入，支持中文。
```

### Change 2: Update Prerequisites checklist

Add to the prerequisites checklist:
```markdown
- [ ] discussion.py 模块可用（`python -m paperforge.worker.discussion --help` 可执行）
```

### Change 3: Mention recording in Output section

After the "已加载论文" display block, add:
```markdown
会话结束后，讨论记录将自动保存至论文工作区 `ai/discussion.json` 和 `ai/discussion.md`。
```
</action>

  <verify>
    <automated>grep -c "保存讨论记录" paperforge/skills/literature-qa/scripts/pf-paper.md</automated>
  </verify>

  <acceptance_criteria>
    - `grep "保存讨论记录" paperforge/skills/literature-qa/scripts/pf-paper.md` returns at least 1 match
    - `grep "paperforge.worker.discussion record" paperforge/skills/literature-qa/scripts/pf-paper.md` returns at least 1 match
    - `grep "仅.*pf-paper.*记录" paperforge/skills/literature-qa/scripts/pf-paper.md` returns at least 1 match (per D-05: pf-deep excluded)
    - `grep "pf-deep.*不记录" paperforge/skills/literature-qa/scripts/pf-paper.md` returns at least 1 match
    - `grep "ai/discussion.json" paperforge/skills/literature-qa/scripts/pf-paper.md` returns at least 1 match
    - `grep "ai/discussion.md" paperforge/skills/literature-qa/scripts/pf-paper.md` returns at least 1 match
    - `grep "user_question" paperforge/skills/literature-qa/scripts/pf-paper.md` returns at least 1 match
    - `grep "agent_analysis" paperforge/skills/literature-qa/scripts/pf-paper.md` returns at least 1 match
    - The original "## See Also" section remains intact (grep confirms its presence)
    - No content from the original prompt (steps 1-7, error handling, output format) is removed or corrupted
  </acceptance_criteria>

  <done>/pf-paper prompt instructs agent to accumulate Q&A pairs and call CLI at session end, with pf-deep explicitly excluded per D-05</done>
</task>

</tasks>

<verification>
Run after both tasks complete:

```bash
# Verify module is importable
python -c "from paperforge.worker.discussion import record_session; print('OK: record_session imported')"

# Verify CLI help works
python -m paperforge.worker.discussion --help

# Verify tests pass
pytest tests/test_discussion.py -v --tb=short

# Verify pf-paper.md has recording step
grep -n "保存讨论记录\|paperforge.worker.discussion record\|ai/discussion" paperforge/skills/literature-qa/scripts/pf-paper.md

# Verify stdlib-only (no third-party imports)
python -c "
import ast, sys
with open('paperforge/worker/discussion.py') as f:
    tree = ast.parse(f.read())
for node in ast.walk(tree):
    if isinstance(node, ast.Import):
        for alias in node.names:
            name = alias.name.split('.')[0]
            if name not in ('json','pathlib','datetime','tempfile','os','uuid','argparse','logging','__future__','paperforge'):
                print(f'THIRD-PARTY: {name}'); sys.exit(1)
    elif isinstance(node, ast.ImportFrom):
        if node.module and node.module.split('.')[0] not in ('__future__','paperforge'):
            print(f'THIRD-PARTY: {node.module}'); sys.exit(1)
print('OK: stdlib + paperforge only')
"
```
</verification>

<success_criteria>
1. `record_session()` creates both `ai/discussion.json` and `ai/discussion.md` in paper workspace
2. Append works: second call adds session without overwriting first
3. Atomic writes via tempfile + os.replace prevent corruption
4. CJK content survives round-trip (Chinese in JSON → read back → matches)
5. CLI subcommand functions: `python -m paperforge.worker.discussion record <key> --vault <path> --agent pf-paper --model <model> --qa-pairs '<json>'`
6. `/pf-paper` prompt updated with step 8 recording instructions, pf-deep explicitly excluded per D-05
7. Module uses stdlib only — zero new pip dependencies
8. All unit tests pass
</success_criteria>

<output>
After completion, create `.planning/phases/35-ai-discussion-recorder/35-01-SUMMARY.md`
</output>
