---
phase: 05-artifact-pipeline
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - vmcluster_mcp/models.py
  - vmcluster_mcp/artifact/registry.py
  - vmcluster_mcp/artifact/__init__.py
  - vmcluster_mcp/server.py
  - tests/test_artifact_registry.py
autonomous: true
requirements: [ART-01, ART-04, ART-05]

must_haves:
  truths:
    - "Agent can register a local build tree and receive a content-addressed artifact_id"
    - "Agent can list all registered artifacts with hash, source path, and timestamp"
    - "Agent can diff two artifacts to see which .ko files and binaries changed"
    - "Registering the same tree twice returns the same artifact_id (dedup)"
    - "Registry survives process restart (persisted to disk)"
  artifacts:
    - path: "vmcluster_mcp/models.py"
      provides: "ArtifactDiff model"
      contains: "class ArtifactDiff"
    - path: "vmcluster_mcp/artifact/registry.py"
      provides: "ArtifactRegistry class with hash_tree, load, save, register, list, diff"
      min_lines: 80
    - path: "vmcluster_mcp/artifact/__init__.py"
      provides: "artifact_register, artifact_list, artifact_diff tool registrations"
      exports: ["artifact_register", "artifact_list", "artifact_diff"]
    - path: "tests/test_artifact_registry.py"
      provides: "Unit tests for registry, register, list, diff"
      min_lines: 50
  key_links:
    - from: "vmcluster_mcp/artifact/__init__.py"
      to: "vmcluster_mcp/artifact/registry.py"
      via: "ArtifactRegistry instance"
      pattern: "ArtifactRegistry\\("
    - from: "vmcluster_mcp/artifact/__init__.py"
      to: "vmcluster_mcp/server.py"
      via: "mcp.tool() + structured_tool_handler"
      pattern: "@mcp\\.tool\\(\\)"
    - from: "vmcluster_mcp/server.py"
      to: "vmcluster_mcp/artifact"
      via: "import triggering tool registration"
      pattern: "import.*artifact"
---

<objective>
Implement the artifact registry core: content-addressed hashing, atomic JSON persistence, and the `artifact_register`, `artifact_list`, and `artifact_diff` MCP tools.

Purpose: Establishes the foundation that `artifact_sync` and `artifact_install` depend on — no artifact can be synced or installed without first being registered.
Output: `artifact/registry.py` with `ArtifactRegistry` class, three wired MCP tools, `ArtifactDiff` model, and unit tests.
</objective>

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

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/05-artifact-pipeline/05-CONTEXT.md
@.planning/phases/05-artifact-pipeline/05-RESEARCH.md

<interfaces>
<!-- Key types and contracts the executor needs. Extracted from codebase. -->

From vmcluster_mcp/models.py:
```python
class BuildArtifact(BaseModel):
    artifact_id: str              # sha256 of tree content
    source_path: str
    build_type: Literal["raw_tree", "make_install", "rpm", "deb"]
    kernel_version: str
    modules: list[str]            # relative paths to .ko files
    userspace_bins: list[str]     # relative paths to userspace binaries
    test_framework_path: str | None = None
    git_sha: str | None = None
    git_branch: str | None = None
    build_timestamp: str          # ISO 8601
    metadata: dict[str, str] = {}

class SyncNodeResult(BaseModel):
    node_name: str
    bytes_transferred: int
    duration_s: float
    rsync_exit_code: int
    skipped: bool

class SyncStatus(BaseModel):
    per_node: dict[str, SyncNodeResult]

class InstallNodeResult(BaseModel):
    node_name: str
    exit_code: int
    installed_module_paths: list[str]
    depmod_output: str

class InstallStatus(BaseModel):
    per_node: dict[str, InstallNodeResult]
```

From vmcluster_mcp/server.py:
```python
config = load_config()   # Config instance with .artifact_registry, .artifact_store_dir
mcp = FastMCP("vmcluster-mcp")

def structured_tool_handler(func):  # Catch-all decorator — wraps every tool
```

From vmcluster_mcp/config.py:
```python
class Config(BaseModel):
    artifact_registry: str = "/var/lib/vmcluster/artifacts/registry.json"
    artifact_store_dir: str = "/var/lib/vmcluster/artifacts/trees"
    # ... other fields
```

From vmcluster_mcp/snapshot/__init__.py (pattern reference):
```python
from vmcluster_mcp.server import config, mcp, structured_tool_handler
# ... tool registrations using @mcp.tool() + @structured_tool_handler
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Add ArtifactDiff model + ArtifactRegistry class with hash_tree and atomic persistence</name>
  <files>vmcluster_mcp/models.py, vmcluster_mcp/artifact/registry.py, tests/test_artifact_registry.py</files>
  <read_first>
    - vmcluster_mcp/models.py (current models — ArtifactDiff must be added after InstallStatus)
    - vmcluster_mcp/snapshot/manager.py (reference for atomic write pattern)
    - vmcluster_mcp/config.py (artifact_registry path)
    - .planning/phases/05-artifact-pipeline/05-CONTEXT.md (D-01 through D-03, D-07)
    - .planning/phases/05-artifact-pipeline/05-RESEARCH.md (hash_tree algorithm)
  </read_first>
  <behavior>
    - Test: hash_tree produces same hash for identical directory trees
    - Test: hash_tree produces different hashes when file content changes
    - Test: hash_tree excludes __pycache__ and .git directories
    - Test: register() creates BuildArtifact with correct artifact_id, modules (.ko scanning), userspace_bins, and ISO 8601 timestamp
    - Test: register() with same tree returns same artifact_id (dedup via key-exists check)
    - Test: save() writes JSON to tmp file then os.replace (atomic write per D-02)
    - Test: load() reads existing registry; returns empty dict if file missing
    - Test: list_artifacts() returns all registered BuildArtifact entries
    - Test: diff() returns ArtifactDiff with changed_modules and changed_bins between two artifacts
    - Test: diff() with unknown artifact_id returns ToolResult(success=False, code="ARTIFACT_NOT_FOUND")
    - Test: register() auto-detects kernel_version from include/generated/utsrelease.h if present; uses caller-provided value otherwise
  </behavior>
  <action>
1. **Add `ArtifactDiff` to `vmcluster_mcp/models.py`** (per D-07) — insert after `InstallStatus`:
   ```python
   class ArtifactDiff(BaseModel):
       artifact_id_a: str
       artifact_id_b: str
       changed_modules: list[str]    # .ko filenames that differ by hash
       changed_bins: list[str]       # userspace binary filenames that differ
       git_log: str | None = None    # git log a_sha..b_sha if both have git_sha
   ```

2. **Create `vmcluster_mcp/artifact/registry.py`** with:
   - `from __future__ import annotations` (D-17)
   - `logger = logging.getLogger("vmcluster_mcp.artifact")` (D-16)
   - `hash_tree(root: str) -> str`: Walk tree with `os.walk`, sort dirs/files, exclude `__pycache__`/`.git`, sha256 each file in 8KB chunks, combine relative-path:hash strings, sha256 the concatenation. Return hex digest.
   - `_detect_kernel_version(root: str) -> str | None`: Check `{root}/include/generated/utsrelease.h` for `#define UTS_RELEASE "..."` pattern; return version string or None.
   - `_scan_modules(root: str) -> list[str]`: `pathlib.Path(root).rglob("*.ko")`, return sorted relative paths.
   - `_scan_bins(root: str) -> list[str]`: Walk tree, check for ELF magic (`\x7fELF`) in first 4 bytes of each non-.ko file, return sorted relative paths.
   - `class ArtifactRegistry`:
     - `__init__(self, registry_path: str)`: Store path, `_registry: dict[str, dict] = {}`
     - `load(self) -> None`: Read JSON file if exists, populate `_registry`; if missing, `_registry = {}`
     - `save(self) -> None`: `os.makedirs(parent, exist_ok=True)`, write to `{path}.tmp`, `os.replace()` to `{path}` (D-02 atomic write)
     - `register(self, source_path, build_type, kernel_version=None, metadata=None) -> BuildArtifact`: Compute hash_tree, check key exists (dedup), auto-detect kernel_version if None, scan modules/bins, create `BuildArtifact`, store in `_registry`, call `save()`, return artifact
     - `list_artifacts(self) -> list[BuildArtifact]`: Return all values as `BuildArtifact` instances
     - `get(self, artifact_id: str) -> BuildArtifact | None`: Lookup by id
     - `diff(self, id_a: str, id_b: str) -> ArtifactDiff`: Look up both, re-hash files in source_path trees, compare per-file hashes, return `ArtifactDiff` with changed_modules and changed_bins. If either source_path is missing, raise `FileNotFoundError`.

3. **Create `tests/test_artifact_registry.py`** with tests for all behaviors listed above. Use `tmp_path` fixture for test directories and registry file.
  </action>
  <verify>
    <automated>cd /home/hornc/repos/vmcluster-mcp && python -m pytest tests/test_artifact_registry.py -x -v && ruff check vmcluster_mcp/artifact/registry.py vmcluster_mcp/models.py</automated>
  </verify>
  <acceptance_criteria>
    - vmcluster_mcp/models.py contains `class ArtifactDiff(BaseModel):`
    - vmcluster_mcp/models.py contains `changed_modules: list[str]`
    - vmcluster_mcp/models.py contains `changed_bins: list[str]`
    - vmcluster_mcp/artifact/registry.py contains `class ArtifactRegistry:`
    - vmcluster_mcp/artifact/registry.py contains `def hash_tree(`
    - vmcluster_mcp/artifact/registry.py contains `os.replace(`
    - vmcluster_mcp/artifact/registry.py contains `from __future__ import annotations`
    - vmcluster_mcp/artifact/registry.py contains `logging.getLogger("vmcluster_mcp.artifact")`
    - tests/test_artifact_registry.py exits 0 with all tests passing
    - ruff check vmcluster_mcp/artifact/registry.py exits 0
  </acceptance_criteria>
  <done>ArtifactDiff model added to models.py; ArtifactRegistry class in registry.py with hash_tree, atomic save/load, register, list, get, diff methods; all tests pass; ruff clean.</done>
</task>

<task type="auto">
  <name>Task 2: Wire artifact_register, artifact_list, artifact_diff tools to MCP server</name>
  <files>vmcluster_mcp/artifact/__init__.py, vmcluster_mcp/server.py</files>
  <read_first>
    - vmcluster_mcp/artifact/__init__.py (currently empty — this is where tools go)
    - vmcluster_mcp/server.py (see import pattern at bottom: `from vmcluster_mcp import cluster, exec, snapshot`)
    - vmcluster_mcp/snapshot/__init__.py (reference pattern for tool registration)
    - vmcluster_mcp/exec/__init__.py (reference pattern for tool registration)
    - vmcluster_mcp/artifact/registry.py (Task 1 output — ArtifactRegistry API)
    - .planning/phases/05-artifact-pipeline/05-CONTEXT.md (D-14, D-15)
  </read_first>
  <action>
1. **Populate `vmcluster_mcp/artifact/__init__.py`** following the exact pattern from `snapshot/__init__.py` and `exec/__init__.py`:
   ```python
   """Artifact management tools — artifact_register, artifact_list, artifact_diff.

   Tools register on the FastMCP server instance imported from server.py.
   All tool handlers are wrapped with @structured_tool_handler (D-14).
   """
   from __future__ import annotations

   import logging

   from vmcluster_mcp.artifact.registry import ArtifactRegistry
   from vmcluster_mcp.models import ArtifactDiff, BuildArtifact, ErrorDetail, ToolResult
   from vmcluster_mcp.server import config, mcp, structured_tool_handler

   logger = logging.getLogger("vmcluster_mcp.artifact")

   _registry = ArtifactRegistry(config.artifact_registry)
   _registry.load()
   ```

2. **Add `artifact_register` tool:**
   ```python
   @mcp.tool()
   @structured_tool_handler
   async def artifact_register(
       source_path: str,
       build_type: str = "raw_tree",
       kernel_version: str | None = None,
       metadata: dict[str, str] | None = None,
   ) -> ToolResult[BuildArtifact]:
   ```
   - Validate `source_path` exists (`os.path.isdir`)
   - Call `_registry.register(source_path, build_type, kernel_version, metadata)`
   - Return `ToolResult(success=True, result=artifact)`
   - If source_path not found: `ToolResult(success=False, error=ErrorDetail(code="SOURCE_NOT_FOUND", message=f"Source path '{source_path}' does not exist or is not a directory", recoverable=False))`

3. **Add `artifact_list` tool:**
   ```python
   @mcp.tool()
   @structured_tool_handler
   async def artifact_list() -> ToolResult[list[BuildArtifact]]:
   ```
   - Call `_registry.list_artifacts()`
   - Return `ToolResult(success=True, result=artifacts)`

4. **Add `artifact_diff` tool:**
   ```python
   @mcp.tool()
   @structured_tool_handler
   async def artifact_diff(
       artifact_id_a: str,
       artifact_id_b: str,
   ) -> ToolResult[ArtifactDiff]:
   ```
   - Validate both ids exist in registry via `_registry.get(id)`
   - If either missing: `ToolResult(success=False, error=ErrorDetail(code="ARTIFACT_NOT_FOUND", message=f"Artifact '{id}' not found in registry", recoverable=False))`
   - Call `_registry.diff(id_a, id_b)`
   - Catch `FileNotFoundError`: return `ToolResult(success=False, error=ErrorDetail(code="SOURCE_NOT_FOUND", message=str(e), recoverable=False))`
   - Return `ToolResult(success=True, result=diff_result)`

5. **Update `vmcluster_mcp/server.py`** — add `artifact` to the import block at the bottom:
   ```python
   from vmcluster_mcp import (
       artifact,
       cluster,
       exec,
       snapshot,
   )
   ```
  </action>
  <verify>
    <automated>cd /home/hornc/repos/vmcluster-mcp && python -m pytest tests/ -x -v && ruff check vmcluster_mcp/artifact/__init__.py vmcluster_mcp/server.py</automated>
  </verify>
  <acceptance_criteria>
    - vmcluster_mcp/artifact/__init__.py contains `@mcp.tool()` (at least 3 occurrences)
    - vmcluster_mcp/artifact/__init__.py contains `@structured_tool_handler` (at least 3 occurrences)
    - vmcluster_mcp/artifact/__init__.py contains `async def artifact_register(`
    - vmcluster_mcp/artifact/__init__.py contains `async def artifact_list(`
    - vmcluster_mcp/artifact/__init__.py contains `async def artifact_diff(`
    - vmcluster_mcp/artifact/__init__.py contains `ArtifactRegistry(config.artifact_registry)`
    - vmcluster_mcp/artifact/__init__.py contains `_registry.load()`
    - vmcluster_mcp/artifact/__init__.py contains `from __future__ import annotations`
    - vmcluster_mcp/server.py contains `artifact,` in the import block
    - ruff check exits 0
    - All existing tests still pass
  </acceptance_criteria>
  <done>Three MCP tools (artifact_register, artifact_list, artifact_diff) registered on the FastMCP server; server.py imports artifact module to trigger registration; all tests pass; ruff clean.</done>
</task>

</tasks>

<verification>
- `python -m pytest tests/ -x` — all tests pass including new artifact registry tests
- `ruff check vmcluster_mcp/` — no lint errors
- `grep "class ArtifactDiff" vmcluster_mcp/models.py` — model exists
- `grep "@mcp.tool" vmcluster_mcp/artifact/__init__.py | wc -l` — returns 3
- `grep "artifact_register\|artifact_list\|artifact_diff" vmcluster_mcp/artifact/__init__.py` — all three tools present
- `grep "os.replace" vmcluster_mcp/artifact/registry.py` — atomic write confirmed
- `python -c "from vmcluster_mcp.server import mcp"` — import succeeds without error
</verification>

<success_criteria>
- ArtifactDiff model in models.py with changed_modules, changed_bins, git_log fields
- ArtifactRegistry class with deterministic hash_tree, atomic JSON persistence, register/list/get/diff
- artifact_register tool computes sha256, scans .ko files and binaries, stores in registry
- artifact_list tool returns all registered artifacts
- artifact_diff tool compares two artifacts' file hashes
- All existing + new tests pass, ruff clean
</success_criteria>

<output>
After completion, create `.planning/phases/05-artifact-pipeline/05-01-SUMMARY.md`
</output>
