---
phase: 06-kernel-observability
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - vmcluster_mcp/observe/classifier.py
  - vmcluster_mcp/observe/dmesg.py
  - vmcluster_mcp/observe/__init__.py
  - vmcluster_mcp/server.py
  - tests/test_classifier.py
  - tests/test_dmesg_mark.py
autonomous: true
requirements:
  - OBS-01

must_haves:
  truths:
    - "dmesg_mark writes a vmcluster-marker-{uuid} string to /dev/kmsg on targeted nodes"
    - "dmesg_mark returns a dict mapping node name to marker string"
    - "All nodes in one dmesg_mark call receive the same marker string (single UUID per call)"
    - "classify_line correctly categorizes panic > oops > kasan > ubsan > warn > normal"
    - "parse_dmesg_line extracts priority, timestamp, facility, level, and text from raw dmesg output"
  artifacts:
    - path: "vmcluster_mcp/observe/classifier.py"
      provides: "Pure classify_line function"
      exports: ["classify_line"]
    - path: "vmcluster_mcp/observe/dmesg.py"
      provides: "Line parsing and marker insertion"
      exports: ["parse_dmesg_line", "insert_marker"]
    - path: "vmcluster_mcp/observe/__init__.py"
      provides: "dmesg_mark tool registration"
      exports: ["dmesg_mark"]
    - path: "tests/test_classifier.py"
      provides: "Classifier unit tests"
    - path: "tests/test_dmesg_mark.py"
      provides: "Line parser + marker insertion + dmesg_mark tool tests"
  key_links:
    - from: "vmcluster_mcp/observe/__init__.py"
      to: "vmcluster_mcp/observe/dmesg.py"
      via: "import insert_marker"
      pattern: "from.*dmesg.*import.*insert_marker"
    - from: "vmcluster_mcp/observe/__init__.py"
      to: "vmcluster_mcp/server.py"
      via: "import mcp, structured_tool_handler"
      pattern: "from vmcluster_mcp.server import.*mcp.*structured_tool_handler"
    - from: "vmcluster_mcp/server.py"
      to: "vmcluster_mcp/observe"
      via: "import triggers tool registration"
      pattern: "observe"
---

<objective>
Build the classifier, dmesg line parser, marker insertion logic, and wire the `dmesg_mark` MCP tool — the first half of kernel observability.

Purpose: Enables agents to insert named dmesg markers before kernel operations, providing the anchor point for subsequent log collection. Also establishes the classifier and parser infrastructure that Plan 06-02 will use for `dmesg_collect`.

Output: `observe/classifier.py`, `observe/dmesg.py` (partial — parse + marker), `observe/__init__.py` (dmesg_mark tool), server.py wiring, 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/06-kernel-observability/06-CONTEXT.md
@.planning/phases/06-kernel-observability/06-RESEARCH.md

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

From vmcluster_mcp/models.py:
```python
class DmesgLine(BaseModel):
    timestamp: float
    level: Literal["debug", "info", "notice", "warning", "err", "crit", "alert", "emerg"]
    facility: str
    text: str
    classified_as: Literal["oops", "panic", "warn", "kasan", "ubsan", "normal"] | None = None

class DmesgResult(BaseModel):
    lines: list[DmesgLine]
    oops_detected: bool
    panic_detected: bool
    warning_count: int
    error_count: int

class ToolResult(BaseModel, Generic[T]):
    success: bool
    result: T | None = None
    error: ErrorDetail | None = None

class ErrorDetail(BaseModel):
    code: str
    message: str
    node: str | None = None
    recoverable: bool = False
```

From vmcluster_mcp/server.py:
```python
mcp = FastMCP("vmcluster-mcp")

def structured_tool_handler(func):
    """Catch-all decorator ensuring every tool returns ToolResult, never a raw exception."""
```

From vmcluster_mcp/exec/ssh.py:
```python
class SSHConnectionManager:
    async def execute(self, ip: str, port: int, user: str, key_path: str, command: str, timeout_s: int = 30) -> ExecResult:
```

From vmcluster_mcp/topology/schema.py:
```python
class NodeSpec(BaseModel):
    name: str; role: str; vcpus: int; memory_mb: int; ip: str
class SshSpec(BaseModel):
    key_path: str; user: str = "root"; connect_timeout_s: int = 30
class TopologySpec(BaseModel):
    cluster_name: str; nodes: list[NodeSpec]; ssh: SshSpec; ...
```

From vmcluster_mcp/topology/parser.py:
```python
def load_topology(name: str, topology_dir: str) -> TopologySpec:
```

From vmcluster_mcp/config.py:
```python
# config.topology_dir — path to topology YAML directory
```

From vmcluster_mcp/exec/__init__.py (reference pattern for tool registration):
```python
from vmcluster_mcp.exec.ssh import SSHConnectionManager
from vmcluster_mcp.server import config, mcp, structured_tool_handler
from vmcluster_mcp.topology.parser import load_topology

_ssh_manager = SSHConnectionManager()

@mcp.tool()
@structured_tool_handler
async def node_exec(cluster_name: str, node_name: str, command: str, ...) -> ToolResult[ExecResult]:
    topo = load_topology(cluster_name, config.topology_dir)
    node = next((n for n in topo.nodes if n.name == node_name), None)
    ...
    exec_result = await _ssh_manager.execute(ip=node.ip, port=22, user=topo.ssh.user, key_path=topo.ssh.key_path, command=command)
    return ToolResult(success=True, result=exec_result)
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Classifier + dmesg line parser</name>
  <files>vmcluster_mcp/observe/classifier.py, vmcluster_mcp/observe/dmesg.py, tests/test_classifier.py</files>
  <read_first>
    - vmcluster_mcp/models.py (DmesgLine model — field types and classified_as literal values)
    - vmcluster_mcp/observe/__init__.py (currently empty — will be populated in Task 2)
    - docs/vmcluster-mcp-design.md (DmesgLine/DmesgResult definitions, dmesg_mark/dmesg_collect signatures)
    - .planning/phases/06-kernel-observability/06-CONTEXT.md (D-08, D-09, D-11 decisions)
    - .planning/phases/06-kernel-observability/06-RESEARCH.md (sections 1-4: format, priority decoding, patterns)
  </read_first>
  <behavior>
    - classify_line("Kernel panic - not syncing: Fatal") == "panic"
    - classify_line("Oops: 0000 [#1] SMP") == "oops"
    - classify_line("KASAN: slab-out-of-bounds in foo") == "kasan"
    - classify_line("UBSAN: shift-out-of-bounds in bar") == "ubsan"
    - classify_line("WARNING: CPU: 0 PID: 1234") == "warn"
    - classify_line("Normal kernel message") == "normal"
    - classify_line("Kernel panic with WARNING:") == "panic" (first match wins, priority order)
    - parse_dmesg_line("<6>[    0.000000] Linux version 6.1.0") returns DmesgLine(timestamp=0.0, level="info", facility="kern", text="Linux version 6.1.0", classified_as="normal")
    - parse_dmesg_line("<4>[   12.345678] WARNING: CPU: 0") returns DmesgLine(timestamp=12.345678, level="warning", facility="kern", text="WARNING: CPU: 0", classified_as="warn")
    - parse_dmesg_line("<1>[   99.999999] Kernel panic") returns DmesgLine(timestamp=99.999999, level="alert", facility="kern", text="Kernel panic", classified_as="panic")
    - parse_dmesg_line("<14>[  100.000000] vmcluster-marker-abc") returns DmesgLine(timestamp=100.0, level="info", facility="user", text="vmcluster-marker-abc", classified_as="normal")
    - parse_dmesg_line("garbage line") returns None
    - parse_dmesg_line with ISO format "<6>2024-01-15T10:30:00,000001+0000 text" returns DmesgLine with converted float timestamp
  </behavior>
  <action>
    **Create `vmcluster_mcp/observe/classifier.py`:**

    Single module with one public function and compiled regex patterns:

    ```python
    import re
    from typing import Literal

    ClassificationType = Literal["oops", "panic", "warn", "kasan", "ubsan", "normal"]

    # Priority-ordered patterns (D-08: first match wins, D-09: case-sensitive)
    _PATTERNS: list[tuple[re.Pattern[str], ClassificationType]] = [
        (re.compile(r"Kernel panic"), "panic"),
        (re.compile(r"Oops:"), "oops"),
        (re.compile(r"KASAN:"), "kasan"),
        (re.compile(r"UBSAN:"), "ubsan"),
        (re.compile(r"WARNING:"), "warn"),
    ]

    def classify_line(text: str) -> ClassificationType:
        for pattern, classification in _PATTERNS:
            if pattern.search(text):
                return classification
        return "normal"
    ```

    **Create `vmcluster_mcp/observe/dmesg.py`:**

    Parsing functions (collection functions added in Plan 06-02):

    ```python
    LEVEL_NAMES = {0: "emerg", 1: "alert", 2: "crit", 3: "err", 4: "warning", 5: "notice", 6: "info", 7: "debug"}
    FACILITY_NAMES = {0: "kern", 1: "user", 2: "mail", 3: "daemon", 4: "auth", 5: "syslog"}

    _RAW_RE = re.compile(r'^<(\d+)>\[\s*(\d+\.\d+)\]\s*(.*)')
    _ISO_RE = re.compile(r'^<(\d+)>(\d{4}-\d{2}-\d{2}T[\d:,]+[+-]\d{4})\s+(.*)')
    ```

    `parse_dmesg_line(line: str) -> DmesgLine | None`:
    - Try _RAW_RE first, then _ISO_RE
    - Extract priority from group 1, decode: level = priority & 7, facility = priority >> 3
    - Map level/facility to string names via lookup dicts (default "unknown" for unmapped facility values)
    - For raw format: timestamp = float(group 2)
    - For ISO format: replace comma with period in microseconds, parse with `datetime.fromisoformat()`, call `.timestamp()` to get float
    - Call `classify_line(text)` from classifier.py to set `classified_as`
    - Return `DmesgLine(timestamp=ts, level=level_name, facility=facility_name, text=text, classified_as=classification)`
    - Return `None` if neither regex matches

    `insert_marker(ssh_manager, ip, port, user, key_path) -> str`:
    - Generate UUID via `uuid.uuid4()`
    - Marker string: `f"vmcluster-marker-{uuid}"`
    - SSH execute: `echo "{marker}" > /dev/kmsg`
    - Return the marker string
    - Note: this is a helper called per-node. The tool handler in __init__.py handles fan-out and topology resolution.

    **Create `tests/test_classifier.py`:**

    Tests for classify_line:
    - Each of the 5 patterns (panic, oops, kasan, ubsan, warn)
    - Normal (no match)
    - Priority ordering (panic vs warn on same line)
    - Empty string → "normal"
    - Pattern embedded in longer text (e.g., "some text KASAN: report here")

    Tests for parse_dmesg_line:
    - Raw format with various priorities (kern/info, kern/warning, user/info for marker)
    - ISO format basic case
    - Invalid/garbage lines return None
    - Classification is applied (e.g., WARNING line gets classified_as="warn")
    - Whitespace variations in timestamp bracket (padded vs not padded)
  </action>
  <verify>
    <automated>cd /home/hornc/repos/vmcluster-mcp && python -m pytest tests/test_classifier.py -x -v</automated>
  </verify>
  <acceptance_criteria>
    - `vmcluster_mcp/observe/classifier.py` exists and contains `def classify_line(`
    - `vmcluster_mcp/observe/dmesg.py` exists and contains `def parse_dmesg_line(` and `def insert_marker(`
    - `tests/test_classifier.py` exists with tests for all 6 classification types
    - `classify_line("Kernel panic - not syncing: x")` returns `"panic"`
    - `classify_line("normal text")` returns `"normal"`
    - `parse_dmesg_line('<6>[    0.000000] test')` returns a `DmesgLine` with `level="info"`, `facility="kern"`, `timestamp=0.0`
    - `parse_dmesg_line('garbage')` returns `None`
    - `pytest tests/test_classifier.py` exits 0
  </acceptance_criteria>
  <done>classify_line correctly classifies all 6 categories with priority ordering; parse_dmesg_line handles raw and ISO formats; insert_marker constructs correct /dev/kmsg echo command; all tests pass</done>
</task>

<task type="auto">
  <name>Task 2: Wire dmesg_mark tool + server import + tests</name>
  <files>vmcluster_mcp/observe/__init__.py, vmcluster_mcp/server.py, tests/test_dmesg_mark.py</files>
  <read_first>
    - vmcluster_mcp/observe/classifier.py (just created in Task 1)
    - vmcluster_mcp/observe/dmesg.py (just created in Task 1 — insert_marker signature)
    - vmcluster_mcp/exec/__init__.py (reference tool registration pattern — @mcp.tool() + @structured_tool_handler, topology loading, SSH fan-out)
    - vmcluster_mcp/server.py (current import block — need to add observe)
    - vmcluster_mcp/topology/parser.py (load_topology signature)
    - vmcluster_mcp/models.py (ErrorDetail, ToolResult)
    - .planning/phases/06-kernel-observability/06-CONTEXT.md (D-01, D-02, D-03, D-12, D-14, D-16, D-17)
  </read_first>
  <action>
    **Create `vmcluster_mcp/observe/__init__.py`** (dmesg_mark tool — dmesg_collect added in Plan 06-02):

    Follow the exact pattern from `exec/__init__.py` and `artifact/__init__.py`:

    ```python
    """Kernel observability tools — dmesg_mark, dmesg_collect.

    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 asyncio
    import logging
    import uuid

    from vmcluster_mcp.exec.ssh import SSHConnectionManager
    from vmcluster_mcp.models import ErrorDetail, ToolResult
    from vmcluster_mcp.observe.dmesg import insert_marker
    from vmcluster_mcp.server import config, mcp, structured_tool_handler
    from vmcluster_mcp.topology.parser import load_topology

    logger = logging.getLogger("vmcluster_mcp.observe")
    _ssh_manager = SSHConnectionManager()
    ```

    `dmesg_mark` tool handler:
    - Signature: `async def dmesg_mark(cluster_name: str, nodes: list[str] | None = None) -> ToolResult[dict[str, str]]`
    - Note: MCP/FastMCP doesn't support `Literal["all"]` default — use `None` to mean "all nodes" (same pattern as node_exec_all)
    - Load topology via `load_topology(cluster_name, config.topology_dir)`
    - Resolve target nodes: if `nodes` is None or empty, use all topology nodes. Otherwise validate each name exists.
    - Generate ONE marker string: `marker = f"vmcluster-marker-{uuid.uuid4()}"` (per D-03: same marker for all nodes)
    - Fan-out with `asyncio.gather`: for each target node, call `insert_marker(_ssh_manager, node.ip, 22, topo.ssh.user, topo.ssh.key_path, marker)` — but since marker is pre-generated, insert_marker should accept the marker string rather than generating it. Adjust: `insert_marker` takes `marker: str` parameter and just runs the SSH echo command.
    - Actually, revise the insert_marker signature: `insert_marker(ssh_manager, ip, port, user, key_path, marker) -> None` — the marker string is generated in the tool handler, insert_marker just does the SSH write.
    - Collect results per node. On per-node failure, record error but continue (D-17 best-effort).
    - Return `ToolResult(success=True, result={node_name: marker for each successful node})`. If ALL nodes fail, return `success=False`.

    **Update `vmcluster_mcp/server.py`:**

    Add `observe` to the import block. The existing block is:
    ```python
    from vmcluster_mcp import (
        artifact,
        cluster,
        exec,
        snapshot,
    )
    ```
    Change to:
    ```python
    from vmcluster_mcp import (
        artifact,
        cluster,
        exec,
        observe,
        snapshot,
    )
    ```

    **Create `tests/test_dmesg_mark.py`:**

    Test the dmesg_mark tool with mocked SSH. Follow the pattern from `tests/test_snapshot_create.py` or `tests/test_artifact_sync.py`:
    - Mock `load_topology` to return a TopologySpec with 2-3 test nodes
    - Mock `SSHConnectionManager.execute` to capture commands
    - Test: `dmesg_mark("test-cluster")` returns `ToolResult(success=True)` with dict mapping all node names to the same marker string
    - Test: marker string starts with `"vmcluster-marker-"` and contains a valid UUID
    - Test: SSH commands sent are `echo "vmcluster-marker-..." > /dev/kmsg` for each node
    - Test: `dmesg_mark("test-cluster", nodes=["node1"])` only targets node1
    - Test: invalid node name returns `ToolResult(success=False)` with NODE_NOT_FOUND
    - Test: SSH failure on one node still returns marker for others (best-effort)
  </action>
  <verify>
    <automated>cd /home/hornc/repos/vmcluster-mcp && python -m pytest tests/test_dmesg_mark.py tests/test_classifier.py -x -v</automated>
  </verify>
  <acceptance_criteria>
    - `vmcluster_mcp/observe/__init__.py` contains `@mcp.tool()` and `@structured_tool_handler` and `async def dmesg_mark(`
    - `vmcluster_mcp/server.py` import block contains `observe` alongside artifact, cluster, exec, snapshot
    - `tests/test_dmesg_mark.py` exists with at least 5 test functions
    - dmesg_mark tool returns `ToolResult[dict[str, str]]`
    - All nodes in a single call get the same marker string (grep for `uuid.uuid4` called once per call, not per node)
    - `pytest tests/test_dmesg_mark.py tests/test_classifier.py` exits 0
  </acceptance_criteria>
  <done>dmesg_mark tool registered on MCP server, returns per-node marker dict, uses single UUID per call, server.py imports observe module, all tests pass</done>
</task>

</tasks>

<verification>
```bash
# All Plan 06-01 tests pass
cd /home/hornc/repos/vmcluster-mcp && python -m pytest tests/test_classifier.py tests/test_dmesg_mark.py -x -v

# Server still imports cleanly
python -c "from vmcluster_mcp.server import mcp; print('server imports OK')"

# Classifier works
python -c "from vmcluster_mcp.observe.classifier import classify_line; assert classify_line('Kernel panic') == 'panic'; assert classify_line('normal') == 'normal'; print('classifier OK')"

# Parser works
python -c "from vmcluster_mcp.observe.dmesg import parse_dmesg_line; line = parse_dmesg_line('<6>[    0.000000] test'); assert line is not None; assert line.level == 'info'; print('parser OK')"
```
</verification>

<success_criteria>
1. `classify_line()` correctly classifies all 6 categories with panic > oops > kasan > ubsan > warn > normal priority
2. `parse_dmesg_line()` handles both raw `[seconds.microseconds]` and ISO timestamp formats
3. `dmesg_mark` MCP tool inserts markers via SSH and returns per-node marker dict
4. Server imports observe module without error
5. All tests pass
</success_criteria>

<output>
After completion, create `.planning/phases/06-kernel-observability/06-01-SUMMARY.md`
</output>
