---
phase: 06-frontmatter-classification
plan: 01
type: tdd
wave: 1
depends_on: []
files_modified:
  - src/corpus_analyzer/search/classifier.py
  - tests/search/test_classifier.py
autonomous: true
requirements:
  - CLASS-04
  - CLASS-05

must_haves:
  truths:
    - "A markdown file with frontmatter `type: skill` is classified as 'skill' with confidence 0.95 and source 'frontmatter'"
    - "A markdown file with frontmatter `component_type: prompt` (no `type:` key) is classified as 'prompt' with confidence 0.95"
    - "When both `type:` and `component_type:` are present and disagree, `type:` wins"
    - "Frontmatter key/value matching is case-insensitive (`Type: Skill` → skill)"
    - "`componentType` camelCase is treated as equivalent to `component_type`"
    - "An unrecognized frontmatter value (e.g. `type: skills`) falls through to rule-based classification with no error"
    - "Frontmatter `tags` containing a substring matching a construct type produces confidence 0.70, source 'frontmatter'"
    - "Tags are ignored when `type:` already resolved a classification"
    - "Malformed YAML frontmatter is treated as absent (falls through to rule-based)"
    - "Files without frontmatter continue to classify via existing rule-based signals with no regression"
    - "Files using existing rule-based path signals return source 'rule_based' and confidence 0.6"
  artifacts:
    - path: "src/corpus_analyzer/search/classifier.py"
      provides: "ClassificationResult dataclass + classify_by_frontmatter() + updated classify_file() returning ClassificationResult"
      exports:
        - "ClassificationResult"
        - "classify_by_frontmatter"
        - "classify_by_rules"
        - "classify_file"
    - path: "tests/search/test_classifier.py"
      provides: "Failing tests written before implementation (RED phase)"
  key_links:
    - from: "classify_file()"
      to: "classify_by_frontmatter()"
      via: "frontmatter path checked before rule-based signals"
      pattern: "classify_by_frontmatter.*returns.*ClassificationResult"
    - from: "classify_file()"
      to: "classify_by_rules()"
      via: "fallback when frontmatter produces no recognized type"
      pattern: "classify_by_rules.*rule_based"
---

<objective>
Implement frontmatter-first construct classification with confidence scoring.

Purpose: Files with explicit `type:` or `component_type:` frontmatter fields should be classified with high confidence (0.95), making `--construct` filtering reliably accurate. Tags provide a softer signal at 0.70. Rule-based classification remains the fallback, unchanged.

Output: Updated `search/classifier.py` with `ClassificationResult` datatype and full frontmatter extraction logic, covered by a TDD test suite.
</objective>

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

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/06-frontmatter-classification/06-CONTEXT.md
@src/corpus_analyzer/search/classifier.py
@tests/search/test_classifier.py
</context>

<feature>
  <name>Frontmatter Classification with Confidence Scores</name>
  <files>
    src/corpus_analyzer/search/classifier.py
    tests/search/test_classifier.py
  </files>

  <behavior>
    The classifier gains explicit frontmatter awareness, producing a `ClassificationResult`
    dataclass instead of a bare `str`. Callers receive the construct type, a confidence float,
    and the classification source string (`"frontmatter"` or `"rule_based"`).

    Cases — all `classify_file()` returns unless noted:

    # --- Frontmatter: type key ---
    type: skill                → ClassificationResult("skill",   0.95, "frontmatter")
    Type: Skill                → ClassificationResult("skill",   0.95, "frontmatter")  # case-insensitive
    type: prompt               → ClassificationResult("prompt",  0.95, "frontmatter")
    type: workflow             → ClassificationResult("workflow", 0.95, "frontmatter")
    type: agent_config         → ClassificationResult("agent_config", 0.95, "frontmatter")
    type: code                 → ClassificationResult("code",    0.95, "frontmatter")
    type: documentation        → ClassificationResult("documentation", 0.95, "frontmatter")

    # --- Frontmatter: component_type key ---
    component_type: skill      → ClassificationResult("skill",  0.95, "frontmatter")  # no type: key present
    componentType: prompt      → ClassificationResult("prompt", 0.95, "frontmatter")  # camelCase alias

    # --- Conflict resolution ---
    type: skill, component_type: prompt  → ClassificationResult("skill", 0.95, "frontmatter")  # type: wins

    # --- Tags (substring match) ---
    tags: [skill-template, other]  → ClassificationResult("skill", 0.70, "frontmatter")  # no type: key
    tags: [SKILL-TEMPLATE]         → ClassificationResult("skill", 0.70, "frontmatter")  # case-insensitive
    tags: [skill], type: prompt    → ClassificationResult("prompt", 0.95, "frontmatter") # tags ignored when type: resolves
    tags: [unrelated-tag]          → falls through to rule-based
    tags: [skills]                 → does NOT match "skill" (substring: "skills" CONTAINS "skill" — check: "skill" in "skills" = True, so this DOES match. Verify the intended spec: "tag `skill-template` matches construct `skill`" means we check `construct in tag`, not `tag in construct`)

    # --- Unknown / unmapped values ---
    type: skills               → fall through to rule-based (no plural normalization)
    type: unknown_type         → fall through to rule-based
    type: 42                   → coerce to "42", no match, fall through
    component_type: unknown    → fall through (check type: first, which is absent, then component_type: falls through)

    # --- Malformed YAML ---
    (parse error)              → treat as no frontmatter, fall through to rule-based

    # --- No frontmatter ---
    .md file, no frontmatter   → existing rule-based signals apply unchanged

    # --- Rule-based results ---
    skills/my-skill.md         → ClassificationResult("skill",  0.6, "rule_based")
    prompts/rewrite.md         → ClassificationResult("prompt", 0.6, "rule_based")
    tool.py                    → ClassificationResult("code",   0.6, "rule_based")
    (no signals)               → ClassificationResult("documentation", 0.5, "rule_based")  # LLM disabled fallback
  </behavior>

  <implementation>
    ## Step 1: Define ClassificationResult

    Add to `search/classifier.py`:

    ```python
    from dataclasses import dataclass

    @dataclass
    class ClassificationResult:
        construct_type: str
        confidence: float
        source: str  # "frontmatter" | "rule_based" | "llm"
    ```

    ## Step 2: Add classify_by_frontmatter()

    ```python
    def classify_by_frontmatter(file_path: Path, text: str) -> ClassificationResult | None:
    ```

    Logic:
    1. Only process `.md`, `.yaml`, `.yml` files (same guard as existing frontmatter code)
    2. Parse with `frontmatter.loads(text)` — on any exception, return `None`
    3. Build a normalized metadata dict: lowercase all keys, map `componenttype` → `component_type`
    4. Check `type` key first: normalize value to lowercase string, look up in `CONSTRUCT_TYPES`
       - Match → return `ClassificationResult(value, 0.95, "frontmatter")`
       - Non-string or unrecognized → continue
    5. Check `component_type` key: same lookup
       - Match → return `ClassificationResult(value, 0.95, "frontmatter")`
    6. Check `tags` key: must be a list; iterate tags, for each tag check `construct in tag.lower()` for each construct in `CONSTRUCT_TYPES`
       - First match → return `ClassificationResult(construct, 0.70, "frontmatter")`
    7. No matches → return `None`

    Tags substring logic: `construct in tag_value.lower()` — e.g. `"skill" in "skill-template"` → True.
    Note: `"skill" in "skills"` is also True — this is acceptable per the spec (substring match is intentional).

    ## Step 3: Update classify_by_rules()

    Change return type to `ClassificationResult | None`. When a rule fires, return
    `ClassificationResult(type_str, 0.6, "rule_based")`. The existing logic is otherwise unchanged.
    The existing frontmatter block inside `classify_by_rules` (name+description+tools → agent_config,
    name+description → skill) should be **removed** or kept as-is — these are DIFFERENT signals
    (structural keys, not a `type:` declaration). Keep them in `classify_by_rules` at 0.6 confidence
    since they are rule signals, not explicit type declarations.

    ## Step 4: Update classify_file()

    Change signature return type to `ClassificationResult`.

    Priority order:
    1. `classify_by_frontmatter(file_path, text)` — if not None, return result
    2. `classify_by_rules(file_path, text)` — if not None, return result
    3. LLM fallback — if enabled, call ollama; wrap LLM result in `ClassificationResult(type, 0.8, "llm")`
    4. Default: `ClassificationResult("documentation", 0.5, "rule_based")`

    ## Key constraints (from CONTEXT.md — LOCKED)

    - `type:` and `component_type:` are the ONLY high-confidence keys (no others)
    - Case-insensitive for both keys and values
    - `componentType` (camelCase) is a recognized alias for `component_type`
    - Unknown values → fall through silently (no logging)
    - Exact match only for construct type values (no plural normalization)
    - Non-string values → coerce to str → attempt match → fall through if unrecognized
    - Fixed 0.95 confidence for any frontmatter match (no boosting)
    - `type:` beats `component_type:` when both present
    - Tags: case-insensitive substring (`construct in tag.lower()`)
    - Tags confidence = 0.70
    - Tags ignored when `type:` resolves

    ## Backward compat

    The `indexer.py` currently calls `classify_file()` and stores the `str` result. This plan
    changes the return type to `ClassificationResult`. The indexer is updated in Plan 02.
    Do NOT modify the indexer in this plan — the RED→GREEN cycle should focus only on
    `classifier.py` and its tests.
  </implementation>
</feature>

<tasks>

<task type="tdd">
  <name>Task 1: TDD — ClassificationResult + frontmatter classification</name>
  <files>
    tests/search/test_classifier.py
    src/corpus_analyzer/search/classifier.py
  </files>
  <action>
    Follow strict RED → GREEN → REFACTOR cycle.

    RED PHASE — add failing tests to tests/search/test_classifier.py BEFORE touching classifier.py:

    Add tests covering ALL behaviors listed in `<behavior>` above:
    - `test_classify_frontmatter_type_skill`: `type: skill` → ClassificationResult("skill", 0.95, "frontmatter")
    - `test_classify_frontmatter_type_case_insensitive`: `Type: Skill` → same result
    - `test_classify_frontmatter_component_type`: `component_type: prompt` (no `type:` key) → ClassificationResult("prompt", 0.95, "frontmatter")
    - `test_classify_frontmatter_camelcase_alias`: `componentType: workflow` → ClassificationResult("workflow", 0.95, "frontmatter")
    - `test_classify_frontmatter_type_beats_component_type`: both present, disagree → `type:` result wins
    - `test_classify_frontmatter_unknown_value_fallthrough`: `type: skills` → rule_based fallback (not frontmatter)
    - `test_classify_frontmatter_nonstring_value_fallthrough`: `type: 42` → rule_based fallback
    - `test_classify_frontmatter_malformed_yaml_fallthrough`: invalid YAML → rule_based fallback
    - `test_classify_frontmatter_tags_substring_match`: `tags: [skill-template]` → ClassificationResult("skill", 0.70, "frontmatter")
    - `test_classify_frontmatter_tags_case_insensitive`: `tags: [PROMPT-HELPER]` → ClassificationResult("prompt", 0.70, "frontmatter")
    - `test_classify_frontmatter_tags_ignored_when_type_resolves`: `type: workflow`, `tags: [skill]` → ClassificationResult("workflow", 0.95, "frontmatter")
    - `test_classify_frontmatter_unmatched_tags_fallthrough`: `tags: [unrelated]` → rule_based
    - Regression tests — existing rule-based tests must still pass with ClassificationResult return type:
      Update all existing tests (e.g. `test_classify_skill_by_path`) to assert against `.construct_type` instead of raw string

    Commit: `test(06-01): add failing tests for frontmatter classification`

    GREEN PHASE — implement in classifier.py:
    1. Add `ClassificationResult` dataclass (as specified in `<implementation>`)
    2. Add `classify_by_frontmatter()` function
    3. Update `classify_by_rules()` return type to `ClassificationResult | None`
    4. Update `classify_file()` return type to `ClassificationResult`
    Run tests until all pass.

    Commit: `feat(06-01): implement frontmatter classification with confidence scores`

    REFACTOR PHASE — clean up if needed:
    - Ensure all type annotations and PEP 257 docstrings are present
    - Verify `ruff check .` and `mypy src/` pass clean
    - Run full test suite: `uv run pytest -v`

    Commit: `refactor(06-01): clean up classifier types and docstrings` (only if changes were needed)
  </action>
  <verify>
    <automated>uv run pytest tests/search/test_classifier.py -v && uv run ruff check src/corpus_analyzer/search/classifier.py && uv run mypy src/corpus_analyzer/search/classifier.py</automated>
    <manual>Confirm all new frontmatter tests are present and GREEN. Confirm no existing tests were deleted.</manual>
    <sampling_rate>run after each TDD phase commit</sampling_rate>
  </verify>
  <done>
    All tests in tests/search/test_classifier.py pass.
    classify_file() returns ClassificationResult with correct confidence and source fields.
    classify_by_frontmatter() is exported and documented.
    ruff and mypy clean on classifier.py.
    Full test suite passes (no regressions): uv run pytest -v shows green.
  </done>
</task>

</tasks>

<verification>
uv run pytest tests/search/test_classifier.py -v
uv run pytest -v
uv run ruff check .
uv run mypy src/
</verification>

<success_criteria>
1. classify_file() returns ClassificationResult (not str) — verified by test assertions on .construct_type, .confidence, .source
2. Frontmatter `type: skill` → confidence=0.95, source="frontmatter"
3. Tags substring match → confidence=0.70, source="frontmatter"
4. Rule-based fallback still fires when no frontmatter match → source="rule_based"
5. No regressions: full test suite green
6. ruff and mypy clean
</success_criteria>

<output>
After completion, create `.planning/phases/06-frontmatter-classification/06-01-SUMMARY.md` summarizing:
- What was built (ClassificationResult fields, key functions, confidence values)
- Test count (new + updated)
- Any implementation surprises or decisions made under Claude's Discretion
- Commits created
</output>
