---
phase: 22-ingestion-pipeline
plan: 02
type: execute
wave: 2
depends_on: ["22-01"]
files_modified:
  - src/ztlctl/services/ingest.py
  - src/ztlctl/controllers/ingest.py
  - src/ztlctl/actions/_ingest.py
  - tests/services/test_ingest_media.py
autonomous: true
requirements: [INGP-03, INGP-04, INGP-06]

must_haves:
  truths:
    - "User can pass a media or transcript file path to ingest_media and receive a captured reference note in the vault"
    - "The captured reference source bundle contains normalized_text, capture_agent, and modalities from transcription output"
    - "ingest_media is a registered action in ActionRegistry with MCP tool auto-generated"
    - "The two-phase workflow produces a captured reference ready for agent annotation to annotated status"
  artifacts:
    - path: "src/ztlctl/services/ingest.py"
      provides: "ingest_media method on IngestService"
      contains: "def ingest_media"
    - path: "src/ztlctl/controllers/ingest.py"
      provides: "ingest_media controller method"
      contains: "def ingest_media"
    - path: "src/ztlctl/actions/_ingest.py"
      provides: "ingest_media ActionDefinition registration"
      contains: "ingest_media"
    - path: "tests/services/test_ingest_media.py"
      provides: "Tests for media ingestion end-to-end"
      min_lines: 60
  key_links:
    - from: "src/ztlctl/services/ingest.py"
      to: "src/ztlctl/services/transcription.py"
      via: "TranscriptionService instantiation with config values"
      pattern: "TranscriptionService"
    - from: "src/ztlctl/services/ingest.py"
      to: "src/ztlctl/services/source_bundles.py"
      via: "normalize_source_bundle with transcription output"
      pattern: "normalize_source_bundle"
    - from: "src/ztlctl/actions/_ingest.py"
      to: "src/ztlctl/controllers/ingest.py"
      via: "ActionDefinition handler lambda"
      pattern: "IngestController.*ingest_media"
---

<objective>
Wire media ingestion into the existing IngestService, IngestController, and ActionRegistry — completing the full pipeline from file input to captured reference note.

Purpose: With TranscriptionService and MediaIngestConfig from Plan 01, this plan adds the `ingest_media` method that transcribes media files and creates captured references with source bundles, then registers it as an action for CLI/MCP auto-generation.

Output: Working `ingest_media` action end-to-end, registered in ActionRegistry, with tests.
</objective>

<execution_context>
@~/.claude/get-shit-done/workflows/execute-plan.md
@~/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/22-ingestion-pipeline/22-CONTEXT.md
@.planning/phases/22-ingestion-pipeline/22-01-SUMMARY.md

@src/ztlctl/services/ingest.py
@src/ztlctl/controllers/ingest.py
@src/ztlctl/actions/_ingest.py
@src/ztlctl/services/transcription.py
@src/ztlctl/config/models.py
@src/ztlctl/services/source_bundles.py

<interfaces>
<!-- From Plan 01 outputs — executor should use these directly. -->

From src/ztlctl/services/transcription.py (created in Plan 01):
```python
@dataclass(frozen=True)
class TranscriptionResult:
    text: str
    language: str | None
    duration_seconds: float | None
    segments: list[dict[str, Any]]
    capture_agent: str
    modalities: list[str]

class TranscriptionService:
    SUPPORTED_EXTENSIONS: frozenset[str]
    def __init__(self, *, whisper_model: str = "base", language: str | None = None, compute_type: str = "int8") -> None: ...
    def transcribe_file(self, path: Path) -> TranscriptionResult | ServiceError: ...
```

From src/ztlctl/config/models.py (modified in Plan 01):
```python
class MediaIngestConfig(BaseModel):
    whisper_model: str = "base"
    language: str | None = None
    compute_type: str = "int8"

class IngestConfig(BaseModel):
    media: MediaIngestConfig = Field(default_factory=MediaIngestConfig)
```

From src/ztlctl/services/ingest.py:
```python
class IngestService(BaseService):
    def _ingest_normalized(self, *, input_kind: str, title: str, body_text: str, ...) -> ServiceResult: ...
    def _create_reference_with_bundle(self, *, title: str, normalized_body: str, ...) -> ServiceResult: ...
```

From src/ztlctl/controllers/base.py:
```python
class BaseController:
    def _run_action(self, action_name: str, kwargs: dict[str, Any], invoke: Callable) -> ServiceResult: ...
```

From src/ztlctl/actions/definitions.py:
```python
class ActionDefinition:
    name: str, description: str, category: str, params: tuple[ActionParam, ...],
    handler: Callable, side_effect: str, cli_group: str, cli_name: str, ...

class ActionParam:
    name: str, type: type, required: bool, description: str, ...
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: IngestService.ingest_media method and IngestController wiring</name>
  <files>src/ztlctl/services/ingest.py, src/ztlctl/controllers/ingest.py, tests/services/test_ingest_media.py</files>
  <read_first>src/ztlctl/services/ingest.py, src/ztlctl/controllers/ingest.py, src/ztlctl/services/transcription.py, src/ztlctl/services/source_bundles.py, src/ztlctl/config/models.py</read_first>
  <behavior>
    - Test: ingest_media with a .vtt transcript file creates a captured reference with source bundle containing normalized_text, capture_agent="transcript-parser", modalities=["text"]
    - Test: ingest_media with a .srt transcript file creates a captured reference
    - Test: ingest_media with a .txt transcript file creates a captured reference
    - Test: ingest_media with nonexistent file returns ServiceResult(ok=False) with NOT_FOUND error
    - Test: ingest_media with unsupported extension returns ServiceResult(ok=False) with UNSUPPORTED_INPUT error
    - Test: ingest_media with dry_run=True returns preview without creating files
    - Test: ingest_media stores source_path in source bundle (original file path, not copied)
    - Test: IngestController.ingest_media delegates through _run_action
  </behavior>
  <action>
    1. Add `ingest_media` method to `IngestService` in `src/ztlctl/services/ingest.py`:
       ```python
       def ingest_media(
           self,
           path: Path,
           *,
           title: str | None = None,
           topic: str | None = None,
           tags: list[str] | None = None,
           session: str | None = None,
           subtype: str | None = None,
           summary: str | None = None,
           dry_run: bool = False,
           no_reweave: bool = False,
       ) -> ServiceResult:
       ```
       Implementation:
       - Resolve path, check exists (return NOT_FOUND ServiceError if not)
       - Check extension is in `TranscriptionService.SUPPORTED_EXTENSIONS` (return UNSUPPORTED_INPUT if not)
       - Create `TranscriptionService` using `self._vault.settings.ingest.media` config values (whisper_model, language, compute_type)
       - Call `transcribe_file(path)` — if returns ServiceError, wrap in ServiceResult(ok=False)
       - Use resolved title (title or path.stem)
       - Build source bundle fields from TranscriptionResult: `normalized_text=result.text`, `capture_agent=result.capture_agent`, `modalities=result.modalities`, `source_path=str(source_path)` (per CONTEXT decision: media files NOT copied)
       - If `dry_run`, return preview via `_ingest_normalized` with dry_run=True
       - Otherwise delegate to `_ingest_normalized` with `input_kind="media"`, `target_type="reference"` (always reference for media — two-phase workflow per INGP-03), `source_kind="media"`, and transcription-derived fields
       - Use `@traced` decorator on `ingest_media`

    2. Add `ingest_media` method to `IngestController` in `src/ztlctl/controllers/ingest.py`, following the exact pattern of `ingest_file`:
       - Accept same params as service method (path as `Path | str`)
       - Build kwargs dict, define `_invoke` closure that calls `IngestService(self._vault).ingest_media(Path(kw["path"]), ...)`
       - Delegate to `self._run_action("ingest_media", kwargs, _invoke)`

    3. Create `tests/services/test_ingest_media.py` with tests. Mock TranscriptionService to avoid faster-whisper dependency. Use the existing vault test fixtures (check `tests/conftest.py` and `tests/services/test_ingest.py` for patterns). Create temporary VTT/SRT/TXT files in test fixtures.

    For the source bundle, pass `source_path` as an additional field in the `source_bundle` dict passed to `_ingest_normalized` — the normalize_source_bundle function accepts arbitrary source_bundle dicts.
  </action>
  <verify>
    <automated>cd /Users/shparki/Documents/Workspace/thatdev/ztlctl && uv run pytest tests/services/test_ingest_media.py -x -v</automated>
  </verify>
  <acceptance_criteria>ingest_media creates captured references with transcription-derived source bundles, handles all file types and error cases, controller delegates through _run_action, tests pass</acceptance_criteria>
  <done>ingest_media end-to-end: file in, transcribe, captured reference out with normalized_text/capture_agent/modalities/source_path in source bundle</done>
</task>

<task type="auto">
  <name>Task 2: Register ingest_media ActionDefinition in ActionRegistry</name>
  <files>src/ztlctl/actions/_ingest.py</files>
  <read_first>src/ztlctl/actions/_ingest.py, src/ztlctl/actions/definitions.py, src/ztlctl/controllers/ingest.py</read_first>
  <action>
    Add `ingest_media` ActionDefinition registration to `_register_ingest_actions()` in `src/ztlctl/actions/_ingest.py`, following the exact pattern of `ingest_file` (per INGP-04):

    ```python
    registry.register(
        ActionDefinition(
            name="ingest_media",
            description="Ingest a media file or transcript into a captured reference.",
            category="ingest",
            params=(
                ActionParam(
                    "path",
                    str,
                    required=True,
                    description="Filesystem path to the media or transcript file.",
                    cli_is_argument=True,
                ),
                ActionParam(
                    "title",
                    str,
                    required=False,
                    default=None,
                    description="Optional title override for the captured reference.",
                ),
                ActionParam(
                    "topic",
                    str,
                    required=False,
                    default=None,
                    description="Optional topic directory under notes/.",
                ),
                ActionParam(
                    "tags",
                    list,
                    required=False,
                    default=None,
                    description="Optional tags applied to the captured reference.",
                    cli_multiple=True,
                ),
                ActionParam(
                    "summary",
                    str,
                    required=False,
                    default=None,
                    description="Optional capture summary hint.",
                ),
                ActionParam(
                    "dry_run",
                    bool,
                    required=False,
                    default=False,
                    description="Preview ingestion without creating files.",
                    cli_flag=True,
                ),
            ),
            handler=lambda vault, **kw: IngestController(vault).ingest_media(**kw),
            side_effect="write",
            mcp_when_to_use="Ingesting audio, video, or transcript files into the vault as captured references for later annotation.",
            mcp_avoid_when="You are ingesting text or URLs — use ingest_text or ingest_url instead.",
            mcp_common_errors=("NOT_FOUND", "UNSUPPORTED_INPUT", "DEPENDENCY_MISSING"),
            cli_group="ingest",
            cli_name="media",
        )
    )
    ```

    This registration ensures both CLI (`ztlctl ingest media <path>`) and MCP (`ingest_media` tool) are auto-generated from ActionRegistry — no manual CLI command file needed (per INGP-04).

    Verify the action shows up in the registry by running a quick check.
  </action>
  <verify>
    <automated>cd /Users/shparki/Documents/Workspace/thatdev/ztlctl && uv run python -c "from ztlctl.actions import get_action_registry; r = get_action_registry(); a = r.get('ingest_media'); assert a is not None, 'ingest_media not registered'; print(f'OK: {a.name} in category={a.category}, params={[p.name for p in a.params]}')" && uv run pytest tests/ -x -q --tb=short 2>&1 | tail -5</automated>
  </verify>
  <acceptance_criteria>ingest_media action registered in ActionRegistry under ingest category, CLI auto-generates `ztlctl ingest media` command, MCP auto-generates `ingest_media` tool, full test suite passes</acceptance_criteria>
  <done>ingest_media action registered — CLI and MCP surfaces auto-generated, all existing tests pass, mypy clean, ruff clean</done>
</task>

</tasks>

<verification>
- `uv run pytest tests/ -x -q` — full test suite passes
- `uv run mypy src/ztlctl/services/ingest.py src/ztlctl/services/transcription.py src/ztlctl/controllers/ingest.py src/ztlctl/actions/_ingest.py` — clean
- `uv run ruff check src/ztlctl/services/ src/ztlctl/controllers/ingest.py src/ztlctl/actions/_ingest.py` — clean
- `uv run python -c "from ztlctl.actions import get_action_registry; r = get_action_registry(); print([a.name for a in r.list_actions() if 'media' in a.name])"` — shows `['ingest_media']`
</verification>

<success_criteria>
- ingest_media accepts media files (mp4, mp3, m4a, wav, ogg, flac, mkv, webm) and transcript files (txt, vtt, srt)
- Captured reference source bundle contains normalized_text, capture_agent, modalities, source_path
- ingest_media registered in ActionRegistry with MCP tool auto-generated
- Two-phase workflow: captured reference created, ready for agent annotation to annotated status
- Full test suite passes, mypy strict, ruff clean
</success_criteria>

<output>
After completion, create `.planning/phases/22-ingestion-pipeline/22-02-SUMMARY.md`
</output>
