---
phase: 26-traceable-ai-context-packs
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
  - paperforge/commands/context.py
  - paperforge/commands/__init__.py
  - paperforge/cli.py
  - tests/test_context.py
autonomous: true
requirements:
  - AIC-02
  - AIC-03
  - AIC-04

must_haves:
  truths:
    - "paperforge context <key> prints a single canonical index entry as JSON"
    - "paperforge context --domain <domain> prints all entries in that domain as a JSON array"
    - "paperforge context --collection <path> prints entries matching that collection path as a JSON array"
    - "paperforge context --all prints all entries as a JSON array"
    - "Each entry in the output clearly lists its included metadata, fulltext, note links, and provenance paths"
    - "When a paper cannot produce AI context, the output explains what source assets are missing or why generation is blocked"
    - "Every item in a context pack can be traced back to its originating PDF, OCR output, and formal note"
  artifacts:
    - path: paperforge/commands/context.py
      provides: "CLI context command — reads canonical index, formats JSON output with provenance"
    - path: paperforge/cli.py
      provides: "context subparser + main dispatch"
    - path: tests/test_context.py
      provides: "Tests for context command output, filtering, and blocking explanations"
  key_links:
    - from: paperforge/commands/context.py:run
      to: paperforge/worker/asset_index:read_index
      via: "read canonical index, filter by key/domain/collection/all, format output"
      pattern: "from paperforge\\.worker\\.asset_index import.*read_index"
    - from: paperforge/commands/context.py output entry
      to: paperforge/worker/asset_index:_build_entry workspace paths
      via: "output references paper_root, main_note_path, fulltext_path, ocr_md_path, pdf_path from index entry"
      pattern: "paper_root|main_note_path|fulltext_path"
---

<objective>
Provide a `paperforge context` CLI command that outputs canonical index entries as JSON, forming the traceable AI entry point. Per D-01, the canonical index entry IS the AI context — no separate pack format is designed. The command reads from the existing canonical index, formats entries with provenance, and explains blocking when AI context is not ready.

Purpose: AIC-02 (single paper context), AIC-03 (collection-level context), AIC-04 (explainable entry points). The CLI command is the core AI integration surface: Agent skills or scripts can call `paperforge context <key>` to get a self-describing JSON bundle that includes all source asset links and readiness explanations.

Output:
- `paperforge/commands/context.py` — context command with single-key, --domain, --collection, --all modes
- `paperforge/cli.py` — context subparser + main dispatch
- `tests/test_context.py` — tests for all command modes and provenance output
</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>
Command module pattern (see paperforge/commands/status.py):
```python
def run(args: argparse.Namespace) -> int:
    vault = getattr(args, "vault_path", None)
    if vault is None:
        from paperforge.config import resolve_vault
        vault = resolve_vault(cli_vault=getattr(args, "vault", None))
    # ... implementation ...
    return exit_code
```

Command registry (paperforge/commands/__init__.py):
```python
_COMMAND_REGISTRY: dict[str, str] = {
    "sync": "paperforge.commands.sync",
    # ... add "context": "paperforge.commands.context",
}
```

CLI dispatch pattern (paperforge/cli.py main function, lines 358-408):
```python
if args.command == "sync":
    from paperforge.commands import sync
    return sync.run(args)
```

Canonical index entry shape (from asset_index.py _build_entry):
```python
{
    "zotero_key": str,           # "ABCDEFG"
    "domain": str,               # "骨科"
    "title": str,                # Full title
    "authors": list[str],        # ["Author A", "Author B"]
    "abstract": str,             # Paper abstract text
    "journal": str,              # Journal name
    "year": str,                 # "2024"
    "doi": str,                  # "10.xxxx/xxxxx"
    "pmid": str,                 # PubMed ID
    "collection_path": str,      # "Parent | Child"
    "collections": list[str],    # ["骨科", "运动医学"]
    "collection_tags": list[str],# ["review"]
    "has_pdf": bool,
    "pdf_path": str,             # "[[wikilink to PDF]]"
    "ocr_status": str,           # "pending" | "processing" | "done" | "failed"
    "ocr_md_path": str,          # "[[wikilink to fulltext.md]]"
    "ocr_json_path": str,        # path to OCR meta.json
    "deep_reading_status": str,  # "pending" | "done"
    "note_path": str,            # flat path (legacy)
    "deep_reading_md_path": str, # workspace deep-reading.md path
    # Workspace paths (canonical):
    "paper_root": str,           # "Literature/骨科/KEY - Title/"
    "main_note_path": str,       # "Literature/骨科/KEY - Title/KEY - Title.md"
    "fulltext_path": str,        # "Literature/骨科/KEY - Title/fulltext.md"
    "deep_reading_path": str,    # "Literature/骨科/KEY - Title/deep-reading.md"
    "ai_path": str,              # "Literature/骨科/KEY - Title/ai/"
    # Derived state:
    "lifecycle": str,            # "indexed" | "pdf_ready" | "fulltext_ready" | "deep_read_done" | "ai_context_ready"
    "health": dict,              # {pdf_health, ocr_health, note_health, asset_health}
    "maturity": dict,            # {level, level_name, checks, blocking}
    "next_step": str,            # "sync" | "ocr" | "repair" | "/pf-deep" | "rebuild index" | "ready"
}
```

Canonical index envelope (from asset_index.py build_envelope):
```python
{
    "schema_version": "2",
    "generated_at": "2026-05-04T12:34:56Z",
    "paper_count": int,
    "items": [entry, ...]
}
```

CLI parser build pattern (from cli.py build_parser):
```python
p_context = sub.add_parser("context", help="Generate traceable AI context pack for paper(s)")
p_context.add_argument("key", nargs="?", help="Zotero citation key for single paper context")
p_context.add_argument("--domain", metavar="DOMAIN", help="Filter by domain")
p_context.add_argument("--collection", metavar="PATH", help="Filter by collection path (prefix match)")
p_context.add_argument("--all", action="store_true", help="All papers in the canonical index")
```

Decisions from CONTEXT.md:
- D-01: Canonical index entry IS the AI context. No separate "context pack" format.
- D-02: AI reads the index entry JSON + referenced assets directly.
- D-05: `paperforge context <key>` for single paper, `--domain`, `--collection`, `--all` for multi-entry.
- D-06: Output is JSON. Default to individual entry (dict), array for multi-entry modes.
- D-09: Provenance is inherent: entries already have `paper_root`, `main_note_path`, `fulltext_path`, `ocr_path`, `note_path`. No additional layer needed.
- D-10: No new dependencies. Everything uses existing asset_index.py index reading.
</interfaces>
</context>

<tasks>

<task type="auto" tdd="false">
<name>Task 1: Create context command module with single-key output and provenance</name>
<files>
  paperforge/commands/context.py
</files>

<read_first>
  paperforge/commands/status.py (command module pattern — run(args) function signature)
  paperforge/worker/asset_index.py (read_index, get_index_path, build_envelope)
</read_first>

<action>
Create `paperforge/commands/context.py` implementing the core context command.

The module must have a `run(args: argparse.Namespace) -> int` entry point that:

1. **Reads the canonical index** via `read_index(vault)` from `paperforge.worker.asset_index`.

2. **Filters entries based on the mode:**
   - If `args.key` is provided: find the single entry whose `zotero_key` matches. Case-sensitive, exact match.
   - If `args.domain` is provided: filter entries where `entry["domain"]` equals the argument (exact match per the agent's discretion).
   - If `args.collection` is provided: filter entries where any element in `entry["collections"]` starts with the argument value (prefix match per the agent's discretion). Use `startswith` for prefix matching.
   - If `args.all` is true: include all entries.
   - If none specified and no key given: print error and return 1.

3. **Formats each entry for output.** Per D-09, the entry already carries all necessary provenance paths. The output format wraps each entry with a `_provenance` block and an `_ai_readiness` block:

   ```python
   def _format_context_entry(entry: dict) -> dict:
       """Wrap a canonical index entry with provenance and readiness metadata."""
       # Determine AI readiness
       lifecycle = entry.get("lifecycle", "indexed")
       health = entry.get("health", {})
       blocking = entry.get("maturity", {}).get("blocking", [])
       
       readiness = {
           "ai_context_ready": lifecycle == "ai_context_ready",
           "lifecycle": lifecycle,
           "maturity_level": entry.get("maturity", {}).get("level", 1),
           "blocking_factors": blocking,
           "next_step": entry.get("next_step", ""),
       }
       
       # If not ai_context_ready, explain why (AIC-04)
       if lifecycle != "ai_context_ready":
           reasons = []
           if not entry.get("has_pdf"):
               reasons.append("No PDF attachment — run sync to import")
           elif entry.get("ocr_status") != "done":
               reasons.append(f"OCR status is '{entry.get('ocr_status', 'pending')}' — run ocr")
           elif entry.get("deep_reading_status") != "done":
               reasons.append("Deep reading not complete — run /pf-deep")
           if health and any(v != "healthy" for v in health.values()):
               unhealthy = [k for k, v in health.items() if v != "healthy"]
               reasons.append(f"Unhealthy asset(s): {', '.join(unhealthy)} — run repair")
           readiness["blocking_explanation"] = "; ".join(reasons) if reasons else "Unknown blocking state"
       
       # Build provenance trace (AIC-02, AIC-04)
       provenance = {
           "paper_root": entry.get("paper_root", ""),
           "main_note_path": entry.get("main_note_path", ""),
           "fulltext_path": entry.get("fulltext_path", ""),
           "ocr_md_path": entry.get("ocr_md_path", ""),
           "pdf_path": entry.get("pdf_path", ""),
           "deep_reading_path": entry.get("deep_reading_path", ""),
           "ai_path": entry.get("ai_path", ""),
           "note_path": entry.get("note_path", ""),
           "deep_reading_md_path": entry.get("deep_reading_md_path", ""),
       }
       
       # Assemble output: metadata subset + provenance + readiness
       output = {
           "_format_version": "1",
           "_context": "Canonical index entry — the AI context per PaperForge D-01",
           "_generated_at": datetime.now(timezone.utc).isoformat(),
           "zotero_key": entry["zotero_key"],
           "domain": entry.get("domain", ""),
           "title": entry.get("title", ""),
           "authors": entry.get("authors", []),
           "abstract": entry.get("abstract", ""),
           "journal": entry.get("journal", ""),
           "year": entry.get("year", ""),
           "doi": entry.get("doi", ""),
           "pmid": entry.get("pmid", ""),
           "collections": entry.get("collections", []),
           "_provenance": provenance,
           "_ai_readiness": readiness,
       }
       return output
   ```

4. **Outputs JSON:** 
   - Single entry (key mode): print the formatted dict as JSON.
   - Multi-entry (--domain, --collection, --all): print a JSON array of formatted dicts.
   - Use `json.dumps(output, ensure_ascii=False, indent=2)` for human-readable output.
   - If no entries match the filter: print `[]` for multi-entry modes, print error and return 1 for key mode.

5. **If key is specified but not found:** print error message and return 1.

Use lazy imports (`from paperforge.worker.asset_index import read_index` inside `run()`) to follow the established pattern from other command modules. 

Import datetime inside the function:
```python
from datetime import datetime, timezone
```

The module should be importable without triggering CLI argument parsing.
</action>

<acceptance_criteria>
- `paperforge/commands/context.py` exists with `def run(args: argparse.Namespace) -> int:`
- `run()` reads canonical index and formats entries with `_provenance` and `_ai_readiness` blocks
- Single key mode outputs a single JSON object, multi-entry mode outputs a JSON array
- Blocking explanation included when lifecycle != "ai_context_ready"
- Missing key returns exit code 1 with error message
</acceptance_criteria>

<verify>
  <automated>python -c "from paperforge.commands.context import run; print('context module importable')"</automated>
</verify>

<done>
context command module exists with single-key context output, provenance trace, and AI readiness explanation.
</done>
</task>

<task type="auto" tdd="false">
<name>Task 2: Wire context command into CLI (subparser + main dispatch + command registry)</name>
<files>
  paperforge/cli.py
  paperforge/commands/__init__.py
</files>

<read_first>
  paperforge/cli.py (build_parser function lines 118-289, main function lines 319-451)
  paperforge/commands/__init__.py (command registry)
</read_first>

<action>
**Change A — Register in command registry (`paperforge/commands/__init__.py`):**
Add to `_COMMAND_REGISTRY`:
```python
"context": "paperforge.commands.context",
```

**Change B — Add subparser in `build_parser()` (`paperforge/cli.py`):**
After the `ocr` subparser block (around line 212-220) or before the `base-refresh` parser, add:
```python
# context (Phase 26: traceable AI context packs)
p_context = sub.add_parser("context", help="Generate traceable AI context pack for paper(s)")
p_context.add_argument(
    "key",
    nargs="?",
    metavar="KEY",
    help="Zotero citation key for a single paper (outputs single JSON object)",
)
p_context.add_argument(
    "--domain",
    metavar="DOMAIN",
    help="Filter by domain (outputs JSON array)",
)
p_context.add_argument(
    "--collection",
    metavar="PATH",
    help="Filter by collection path (prefix match, outputs JSON array)",
)
p_context.add_argument(
    "--all",
    action="store_true",
    help="Output all entries in the canonical index (JSON array)",
)
```

**Change C — Add dispatch in `main()` (`paperforge/cli.py`):**
After the `deep-reading` dispatch block (around line 402) or before `base-refresh`, add:
```python
if args.command == "context":
    from paperforge.commands import context
    return context.run(args)
```

Do NOT add `--json` flag — the context command always outputs JSON (per D-06).

Validation on dispatch: at least one of `key`, `--domain`, `--collection`, `--all` must be provided. The context module's `run()` already handles this validation; no need to duplicate in CLI.

Make sure `argparse` properly handles `nargs="?"` for the optional key argument.
</action>

<acceptance_criteria>
- `paperforge/commands/__init__.py` registers `"context": "paperforge.commands.context"`
- `paperforge/cli.py` `build_parser()` creates `context` subparser with `key`, `--domain`, `--collection`, `--all` arguments
- `paperforge/cli.py` `main()` dispatches `context` to `paperforge.commands.context.run(args)`
- `python -m paperforge context --help` shows usage with all arguments
</acceptance_criteria>

<verify>
  <automated>python -m paperforge context --help</automated>
</verify>

<done>
CLI wiring complete: `paperforge context` command available with all four access modes (key, --domain, --collection, --all).
</done>
</task>

<task type="auto" tdd="false">
<name>Task 3: Write tests for context command modes, filtering, and provenance output</name>
<files>
  tests/test_context.py
</files>

<read_first>
  paperforge/commands/context.py (run function from Task 1)
  paperforge/worker/asset_index.py (read_index, build_envelope)
  tests/test_status.py (existing test patterns for CLI command testing)
</read_first>

<action>
Create `tests/test_context.py` with comprehensive tests. Use `tmp_path` for vault root and `monkeypatch` for path resolution. Create mock canonical index files using `atomic_write_index` or `json.dump` directly.

**Test fixture:** Create a helper function `_make_index(vault, entries)` that writes a valid canonical index envelope with the given entries to `get_index_path(vault)`.

**Test 1 — `test_context_single_key` (AIC-02):**
- Create a mock index with 2 entries (keys: "KEY001", "KEY002")
- Build args Namespace with `key="KEY001"`, `domain=None`, `collection=None`, `all=False`
- Call `run(args)` 
- Assert: prints a single JSON object (not array)
- Assert: printed JSON has `zotero_key: "KEY001"`
- Assert: printed JSON has `_provenance` with all path fields
- Assert: printed JSON has `_ai_readiness` with `ai_context_ready` and `lifecycle`

**Test 2 — `test_context_single_key_not_found`:**
- Create mock index with "KEY001" only
- Build args with `key="KEY999"`
- Call `run(args)`
- Assert: returns 1 (error)
- Assert: prints error message containing "not found" or similar

**Test 3 — `test_context_domain_filter` (AIC-03):**
- Create mock index with entries in "骨科", "运动医学", "骨科" domains
- Build args with `domain="骨科"`, `key=None`, `collection=None`, `all=False`
- Call `run(args)`
- Assert: prints a JSON array
- Assert: array has 2 entries, both with `domain: "骨科"`

**Test 4 — `test_context_collection_filter` (AIC-03):**
- Create mock index with entries having `collections: ["骨科/脊柱", "骨科/关节"]`, `collections: ["运动医学"]`, `collections: ["骨科/创伤"]`
- Build args with `collection="骨科"`, `key=None`, `domain=None`, `all=False`
- Call `run(args)`
- Assert: prints JSON array with 2 entries (both starting with "骨科")
- Assert: the "运动医学" entry is excluded

**Test 5 — `test_context_all`:**
- Create mock index with 3 entries
- Build args with `all=True`, `key=None`, `domain=None`, `collection=None`
- Call `run(args)`
- Assert: prints JSON array with all 3 entries

**Test 6 — `test_context_provenance_traceability` (AIC-04):**
- Create a mock entry with all fields populated
- Build args with `key` matching that entry
- Assert: output `_provenance` contains all path keys: `paper_root`, `main_note_path`, `fulltext_path`, `ocr_md_path`, `pdf_path`, `deep_reading_path`, `ai_path`, `note_path`
- Assert: all path values are non-empty strings

**Test 7 — `test_context_ai_readiness_blocking_explanation` (AIC-04):**
- Create entries at different lifecycle stages: "pdf_ready" (no fulltext), "fulltext_ready" (no deep read), "ai_context_ready"
- For the "pdf_ready" entry: assert `_ai_readiness.blocking_explanation` contains information about missing OCR or deep reading
- For the "ai_context_ready" entry: assert `_ai_readiness.ai_context_ready` is true, `blocking_factors` is empty

**Test 8 — `test_context_no_entries_match`:**
- Create mock index with "KEY001" only
- Build args with `domain="Nonexistent"`
- Call `run(args)`
- Assert: prints `[]`
- Assert: returns 0 (empty array is success, no error)

Use `pytest` with `unittest.mock.patch` or `monkeypatch` to control the vault path. Use `argparse.Namespace` for args construction:
```python
import argparse
args = argparse.Namespace(key="KEY001", domain=None, collection=None, all=False, vault_path=tmp_path)
```
</action>

<acceptance_criteria>
- `tests/test_context.py` exists with 8+ test cases covering all modes
- All tests pass: `pytest tests/test_context.py -x -q --tb=short`
- Tests cover single key, domain filter, collection filter, all, missing key, no-match, provenance content, and blocking explanation
</acceptance_criteria>

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

<done>
All 8+ context command tests pass. Context command is verified for single-key, domain, collection, all modes, provenance, and blocking explanations.
</done>
</task>

</tasks>

<verification>
1. `python -m paperforge context --help` shows usage with all arguments
2. `pytest tests/test_context.py -x -q --tb=short` — all tests pass
3. `python -c "from paperforge.commands.context import run; from paperforge.commands import get_command_module; m = get_command_module('context'); print('registry OK')"` — registry works
4. Manual test: create a mock canonical index and run `python -m paperforge context <key>` on it
</verification>

<success_criteria>
- AIC-02: `paperforge context <key>` outputs traceable context with metadata, fulltext, note links, and provenance
- AIC-03: `paperforge context --domain` and `--collection` outputs filtered context without hardcoded extraction schemas
- AIC-04: Each entry includes blocking explanation when AI context is not ready, and provenance paths for every source asset
- D-09: Provenance is inherent — no additional layer needed, entry paths are sufficient
- D-10: No new dependencies beyond existing asset_index.py
</success_criteria>

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