# Shape-Aware Spec Validation Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Extend `claw-forge validate-spec` with a Layer 4 (Shape) checker that catches every shape-awareness gap left after PR #25 — typos, missing plugin attributes, core/plugin overlaps, brownfield directory mismatches, vertical-category nudges, long core dependency chains — and extend `/fix-spec` to deterministically auto-fix or surface structured manual-review prompts for every issue the validator emits.

**Architecture:** A new `run_shape_checks()` function in `claw_forge/spec/validator.py` runs five check functions (one per-feature, two cross-feature, one heuristic, one filesystem). The CLI gains `--strict-shape` and `--project-root` flags and a fourth output block. The parser tightens to raise on unknown `shape=` values (mirroring the existing `shape="core"` requires `touches_files` invariant). `/fix-spec.md` gains a parse-error pre-pass, a Layer 4 fix table, and a Manual Review block.

**Tech Stack:** Python 3.12, Typer (CLI), pytest + CliRunner (tests), fnmatch (glob overlap), xml.etree.ElementTree (existing parser), markdown prose (slash command).

**Spec:** `docs/superpowers/specs/2026-05-07-shape-aware-spec-validation-design.md`

---

## File Structure

**Files modified:**
- `claw_forge/spec/parser.py` — tighten shape attribute reading (lines 250–254)
- `claw_forge/spec/validator.py` — add Layer 4 (~250 new lines)
- `claw_forge/cli.py` — `validate_spec_cmd` flags + Layer 4 output block + `_resolve_project_root` helper (~80 new lines around line 2559)
- `.claude/commands/fix-spec.md` — Step 0 pre-pass, Layer 4 fix table, Manual Review block
- `tests/spec/test_parser.py` — extend `<feature shape>` test class with typo-raise tests (~25 new lines after line 1166)
- `tests/spec/test_validator.py` — extend with `validate_spec` integration tests for Layer 4 (~80 new lines)
- `tests/e2e/test_cli_e2e.py` — new `TestValidateSpecShape` class (~120 new lines)
- `CLAUDE.md` — add Layer 4 paragraph under spec/parser.py section
- `docs/commands.md` — add `--strict-shape` and Layer 4 entries under `validate-spec`
- `README.md` — one sentence in spec-format section

**Files created:**
- `tests/spec/test_validator_shape.py` — Layer 4 unit tests (~400 lines, one class per check function)
- `tests/test_cli_validate_spec_strict_shape.py` — CLI flag wiring tests (~120 lines)
- `tests/e2e/fixtures/specs_with_shape/clean.xml` — fully shape-annotated fixture
- `tests/e2e/fixtures/specs_with_shape/mixed.xml` — partial shape, vertical-looking unannotated category
- `tests/e2e/fixtures/specs_with_shape/bad.xml` — gap-1 violation (plugin without `plugin=`)

---

## Task 1: Parser tightens — unknown `shape=` values raise

**Files:**
- Modify: `claw_forge/spec/parser.py:250-254`
- Test: `tests/spec/test_parser.py` (new test methods in the existing `class` covering `<feature shape>`, after line 1166)

- [ ] **Step 1: Read existing parser shape-handling code**

Read `claw_forge/spec/parser.py:250-280` to confirm the current `shape_attr` lines. Verify that existing tests at `tests/spec/test_parser.py:949+` cover only valid values (`plugin`, `core`, missing).

- [ ] **Step 2: Write failing tests for typo-raise**

Append to `tests/spec/test_parser.py` inside the existing class that contains `test_feature_shape_plugin_is_parsed`:

```python
def test_unknown_shape_attr_raises(self) -> None:
    """Non-empty unrecognized shape value must raise — silent coercion to
    None would let typos slip through to the dispatcher."""
    xml = """<?xml version="1.0"?>
<project>
  <project_name>Test</project_name>
  <overview>x</overview>
  <tech_stack></tech_stack>
  <features>
    <category name="Auth">
      <feature index="1" shape="ploogin" plugin="auth">
        <description>User can register with email and password</description>
      </feature>
    </category>
  </features>
</project>"""
    with pytest.raises(ValueError, match="unrecognized shape"):
        ProjectSpec.from_string(xml)

def test_empty_shape_attr_still_parses_as_none(self) -> None:
    """Back-compat: shape="" (empty string) is treated as no annotation."""
    xml = """<?xml version="1.0"?>
<project>
  <project_name>Test</project_name>
  <overview>x</overview>
  <tech_stack></tech_stack>
  <features>
    <category name="Auth">
      <feature index="1" shape="">
        <description>User can register with email and password</description>
      </feature>
    </category>
  </features>
</project>"""
    spec = ProjectSpec.from_string(xml)
    assert spec.features[0].shape is None

def test_shape_attr_case_insensitive(self) -> None:
    """shape="PLUGIN" normalizes to "plugin" — common author mistake."""
    xml = """<?xml version="1.0"?>
<project>
  <project_name>Test</project_name>
  <overview>x</overview>
  <tech_stack></tech_stack>
  <features>
    <category name="Auth">
      <feature index="1" shape="PLUGIN" plugin="auth">
        <description>User can register with email and password</description>
      </feature>
    </category>
  </features>
</project>"""
    spec = ProjectSpec.from_string(xml)
    assert spec.features[0].shape == "plugin"
```

- [ ] **Step 3: Run tests to verify they fail**

Run: `uv run pytest tests/spec/test_parser.py -k "test_unknown_shape_attr_raises or test_empty_shape_attr_still_parses_as_none or test_shape_attr_case_insensitive" -v`

Expected: `test_unknown_shape_attr_raises` FAILS (no exception raised — silent coerce to None). The other two should already PASS (back-compat preserved).

- [ ] **Step 4: Tighten the parser**

Edit `claw_forge/spec/parser.py:250-254`. Replace:

```python
                    # Architectural shape.  Empty / unrecognized → None.
                    shape_attr = feat_el.get("shape", "").strip().lower()
                    feat_shape: str | None = (
                        shape_attr if shape_attr in {"plugin", "core"} else None
                    )
```

With:

```python
                    # Architectural shape.  Empty → None (no annotation).
                    # Unrecognized non-empty → ValueError (silent coercion
                    # would let typos slip through to the dispatcher).
                    shape_attr = feat_el.get("shape", "").strip().lower()
                    if shape_attr == "":
                        feat_shape: str | None = None
                    elif shape_attr in {"plugin", "core"}:
                        feat_shape = shape_attr
                    else:
                        raise ValueError(
                            f"<feature shape='{shape_attr}'> '{short_name}' "
                            f"has unrecognized shape value. "
                            f"Allowed: 'plugin', 'core', or omit the attribute."
                        )
```

Note: `short_name` is already in scope at this point (set at line 229).

- [ ] **Step 5: Run tests to verify all three pass**

Run: `uv run pytest tests/spec/test_parser.py -k "test_unknown_shape_attr_raises or test_empty_shape_attr_still_parses_as_none or test_shape_attr_case_insensitive" -v`

Expected: 3 PASS.

Then run the full parser suite to confirm no regression:

Run: `uv run pytest tests/spec/test_parser.py -q`

Expected: All pass.

- [ ] **Step 6: Commit**

```bash
git add claw_forge/spec/parser.py tests/spec/test_parser.py
git commit -m "$(cat <<'EOF'
feat(parser): raise on unknown <feature shape> values

Silent coercion to None let typos like shape="ploogin" slip through
to the dispatcher with shape=None — the spec author thought they
declared shape but didn't. Mirrors the existing core/touches_files
parse-time invariant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 2: Validator — Layer 4 module constants and helpers

**Files:**
- Modify: `claw_forge/spec/validator.py` (add new section near the end, before the existing `validate_spec` function at line 614)
- Test: `tests/spec/test_validator_shape.py` (new file)

- [ ] **Step 1: Create the new test file with header**

Create `tests/spec/test_validator_shape.py`:

```python
"""Layer 4 (Shape) validator tests.

One class per check function; one entrypoint test class for run_shape_checks.
Boundary cases for the heuristic threshold are covered in
TestCheckCategoryLooksVertical.
"""
from __future__ import annotations

from pathlib import Path

import pytest

from claw_forge.spec.parser import FeatureItem, ProjectSpec
from claw_forge.spec.validator import (
    CORE_CHAIN_DEPTH_THRESHOLD,
    CROSS_CUTTING_INDICATORS,
    IssueSeverity,
    VERTICAL_INDICATORS,
    VERTICAL_MIN_BULLETS,
    VERTICAL_RATIO_THRESHOLD,
    _slugify_plugin_name,
)


class TestSlugifyPluginName:
    """Convert category names to plugin slugs — used by /fix-spec auto-fix
    recipes and the Gap 6 nudge message."""

    def test_simple_lowercase(self) -> None:
        assert _slugify_plugin_name("Authentication") == "authentication"

    def test_strips_non_alphanumerics(self) -> None:
        assert _slugify_plugin_name("User Management!") == "user-management"

    def test_collapses_whitespace_to_single_dash(self) -> None:
        assert _slugify_plugin_name("Order   Processing") == "order-processing"

    def test_strips_leading_trailing_dashes(self) -> None:
        assert _slugify_plugin_name("--Billing--") == "billing"

    def test_empty_string_returns_empty(self) -> None:
        assert _slugify_plugin_name("") == ""

    def test_only_specials_returns_empty(self) -> None:
        assert _slugify_plugin_name("!!!") == ""


class TestModuleConstants:
    """Module-level vocabulary and thresholds are public and importable."""

    def test_vertical_indicators_is_frozenset(self) -> None:
        assert isinstance(VERTICAL_INDICATORS, frozenset)
        assert "user can" in VERTICAL_INDICATORS

    def test_cross_cutting_indicators_is_frozenset(self) -> None:
        assert isinstance(CROSS_CUTTING_INDICATORS, frozenset)
        assert "middleware" in CROSS_CUTTING_INDICATORS

    def test_thresholds_are_numeric(self) -> None:
        assert 0.0 <= VERTICAL_RATIO_THRESHOLD <= 1.0
        assert VERTICAL_MIN_BULLETS >= 1
        assert CORE_CHAIN_DEPTH_THRESHOLD >= 2
```

- [ ] **Step 2: Run the test file to verify it fails on imports**

Run: `uv run pytest tests/spec/test_validator_shape.py -v`

Expected: ImportError or AttributeError on `_slugify_plugin_name`, `VERTICAL_INDICATORS`, etc. (none of these exist yet).

- [ ] **Step 3: Add the constants and helper to `claw_forge/spec/validator.py`**

Append after line 611 (the end of `run_llm_evaluation`), and before `validate_spec` at line 614:

```python
# ---------------------------------------------------------------------------
# Layer 4: architectural-shape coherence checks
# ---------------------------------------------------------------------------

# Heuristic vocabulary for "looks vertical" detection (Gap 6).  Bullets
# matching VERTICAL_INDICATORS score +1; matching CROSS_CUTTING_INDICATORS
# score -1.  A category is flagged when (vertical-score / |unannotated|)
# meets VERTICAL_RATIO_THRESHOLD with at least VERTICAL_MIN_BULLETS bullets.
VERTICAL_INDICATORS: frozenset[str] = frozenset({
    "user can", "user cannot", "displays", "shows", "submits",
    "registers", "logs in", "logs out", "creates", "deletes", "edits",
})
CROSS_CUTTING_INDICATORS: frozenset[str] = frozenset({
    "every endpoint", "all endpoints", "middleware", "intercepts",
    "all requests", "globally", "across", "any plugin", "every plugin",
    "rate limit", "audit log", "metrics", "tracing",
})
VERTICAL_RATIO_THRESHOLD: float = 0.6
VERTICAL_MIN_BULLETS: int = 3
CORE_CHAIN_DEPTH_THRESHOLD: int = 4


def _slugify_plugin_name(category: str) -> str:
    """Convert a category name to a plugin-attribute slug.

    Lowercase, replace non-alphanumerics with whitespace, collapse runs
    of whitespace to single dashes, strip leading/trailing dashes.

    Distinct from claw_forge.git.slug.make_slug — that helper strips
    leading action verbs for branch names; here we keep the noun.
    """
    import re

    cleaned = re.sub(r"[^a-z0-9]+", " ", category.lower()).strip()
    return re.sub(r"\s+", "-", cleaned)
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `uv run pytest tests/spec/test_validator_shape.py -v`

Expected: 9 PASS.

- [ ] **Step 5: Commit**

```bash
git add claw_forge/spec/validator.py tests/spec/test_validator_shape.py
git commit -m "$(cat <<'EOF'
feat(validator): add Layer 4 module constants and slug helper

Vocabulary frozensets for Gap 6 heuristic, threshold constants, and
_slugify_plugin_name for /fix-spec auto-fix recipes. Module-level so
they're importable for tests and tunable in one place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 3: Validator — `check_plugin_shape_complete` (Gap 1)

**Files:**
- Modify: `claw_forge/spec/validator.py` (append after the constants from Task 2)
- Test: `tests/spec/test_validator_shape.py` (append new test class)

- [ ] **Step 1: Write failing tests**

Append to `tests/spec/test_validator_shape.py`:

```python
class TestCheckPluginShapeComplete:
    """Gap 1 — shape='plugin' must have plugin= or non-empty touches_files.
    Always ERROR (invariant: silent disable of file-claim locking)."""

    def _feat(self, **overrides) -> FeatureItem:
        defaults = {
            "category": "Auth",
            "name": "register",
            "description": "User can register with email and password",
            "shape": "plugin",
            "plugin": None,
            "touches_files": [],
        }
        defaults.update(overrides)
        return FeatureItem(**defaults)

    def test_plugin_with_plugin_attr_passes(self) -> None:
        from claw_forge.spec.validator import check_plugin_shape_complete

        feat = self._feat(plugin="auth", touches_files=["src/plugins/auth/**"])
        assert check_plugin_shape_complete(feat, strict=False) is None

    def test_plugin_with_explicit_touches_passes(self) -> None:
        from claw_forge.spec.validator import check_plugin_shape_complete

        feat = self._feat(plugin=None, touches_files=["src/auth/auth.py"])
        assert check_plugin_shape_complete(feat, strict=False) is None

    def test_plugin_missing_both_fails(self) -> None:
        from claw_forge.spec.validator import check_plugin_shape_complete

        feat = self._feat(plugin=None, touches_files=[])
        issue = check_plugin_shape_complete(feat, strict=False)
        assert issue is not None
        assert issue.severity == IssueSeverity.ERROR
        assert issue.layer == 4
        assert "plugin=" in issue.message or "touches_files" in issue.message

    def test_plugin_missing_both_strict_still_error(self) -> None:
        """Strict flag does not affect Gap 1 — already always ERROR."""
        from claw_forge.spec.validator import check_plugin_shape_complete

        feat = self._feat(plugin=None, touches_files=[])
        issue = check_plugin_shape_complete(feat, strict=True)
        assert issue is not None
        assert issue.severity == IssueSeverity.ERROR

    def test_core_shape_skipped(self) -> None:
        """Gap 1 only applies to shape='plugin'; core/None untouched."""
        from claw_forge.spec.validator import check_plugin_shape_complete

        feat = self._feat(shape="core", touches_files=["src/middleware.py"])
        assert check_plugin_shape_complete(feat, strict=False) is None

    def test_unannotated_shape_skipped(self) -> None:
        from claw_forge.spec.validator import check_plugin_shape_complete

        feat = self._feat(shape=None, plugin=None, touches_files=[])
        assert check_plugin_shape_complete(feat, strict=False) is None

    def test_issue_includes_bullet_text(self) -> None:
        from claw_forge.spec.validator import check_plugin_shape_complete

        feat = self._feat(plugin=None, touches_files=[])
        issue = check_plugin_shape_complete(feat, strict=False)
        assert issue is not None
        assert issue.bullet == "User can register with email and password"

    def test_issue_suggestion_names_category_slug(self) -> None:
        from claw_forge.spec.validator import check_plugin_shape_complete

        feat = self._feat(category="User Management", plugin=None, touches_files=[])
        issue = check_plugin_shape_complete(feat, strict=False)
        assert issue is not None
        assert "user-management" in issue.suggestion
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `uv run pytest tests/spec/test_validator_shape.py::TestCheckPluginShapeComplete -v`

Expected: ImportError on `check_plugin_shape_complete`.

- [ ] **Step 3: Implement the check function**

Append to `claw_forge/spec/validator.py` (after the constants/helpers from Task 2):

```python
def check_plugin_shape_complete(
    feat: FeatureItem, *, strict: bool,
) -> ValidationIssue | None:
    """Gap 1 — shape='plugin' must have plugin= or non-empty touches_files.

    Always ERROR.  Silent acceptance would mean the dispatcher dispatches
    the task without file-claim locking, breaking the parallel-safety
    guarantee that shape-aware scheduling promises.  ``strict`` is accepted
    for API consistency but does not change behavior.
    """
    del strict  # gap 1 is always ERROR
    if feat.shape != "plugin":
        return None
    if feat.plugin or feat.touches_files:
        return None
    suggested_slug = _slugify_plugin_name(feat.category) or "<plugin-name>"
    return ValidationIssue(
        severity=IssueSeverity.ERROR,
        layer=4,
        category=feat.category,
        bullet=feat.description,
        message=(
            f"shape='plugin' feature missing both plugin= and touches_files= "
            f"— file-claim locking is silently disabled."
        ),
        suggestion=(
            f"Add plugin=\"{suggested_slug}\" to the <feature> element, "
            f"or declare an explicit touches_files= attribute."
        ),
    )
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `uv run pytest tests/spec/test_validator_shape.py::TestCheckPluginShapeComplete -v`

Expected: 8 PASS.

- [ ] **Step 5: Commit**

```bash
git add claw_forge/spec/validator.py tests/spec/test_validator_shape.py
git commit -m "$(cat <<'EOF'
feat(validator): Gap 1 check — plugin shape requires plugin or touches_files

Silent acceptance of shape='plugin' with no plugin= and no touches_files=
disables file-claim locking, breaking shape-aware scheduling's
parallel-safety guarantee. Always ERROR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 4: Validator — `check_touches_overlap` (Gap 3)

**Files:**
- Modify: `claw_forge/spec/validator.py` (append)
- Test: `tests/spec/test_validator_shape.py` (append)

- [ ] **Step 1: Write failing tests**

Append to `tests/spec/test_validator_shape.py`:

```python
class TestCheckTouchesOverlap:
    """Gap 3 — core feature touches_files glob overlaps a plugin's directory.
    Conservative algorithm: false-negatives over false-positives.
    WARNING by default; ERROR in strict mode."""

    def _spec(self, features: list[FeatureItem]) -> ProjectSpec:
        from claw_forge.spec.parser import TechStack

        return ProjectSpec(
            project_name="t",
            overview="o",
            tech_stack=TechStack(),
            features=features,
            implementation_phases=[],
            success_criteria=[],
        )

    def _plugin(self, name: str) -> FeatureItem:
        return FeatureItem(
            category="Cat",
            name=f"{name}-feat",
            description=f"User can do {name}",
            shape="plugin",
            plugin=name,
            touches_files=[f"src/plugins/{name}/**"],
        )

    def _core(self, *globs: str) -> FeatureItem:
        return FeatureItem(
            category="Core",
            name="core-feat",
            description="System enforces JWT on every endpoint",
            shape="core",
            touches_files=list(globs),
        )

    def test_no_overlap_passes(self) -> None:
        from claw_forge.spec.validator import check_touches_overlap

        spec = self._spec([
            self._plugin("auth"),
            self._core("src/core/middleware/jwt.py"),
        ])
        assert check_touches_overlap(spec, strict=False) == []

    def test_sloppy_core_glob_overlaps_plugin(self) -> None:
        """src/** matches every plugin directory — flagged."""
        from claw_forge.spec.validator import check_touches_overlap

        spec = self._spec([
            self._plugin("auth"),
            self._core("src/**"),
        ])
        issues = check_touches_overlap(spec, strict=False)
        assert len(issues) == 1
        assert issues[0].severity == IssueSeverity.WARNING
        assert issues[0].layer == 4
        assert "src/plugins/auth/" in issues[0].message

    def test_strict_promotes_to_error(self) -> None:
        from claw_forge.spec.validator import check_touches_overlap

        spec = self._spec([
            self._plugin("auth"),
            self._core("src/**"),
        ])
        issues = check_touches_overlap(spec, strict=True)
        assert len(issues) == 1
        assert issues[0].severity == IssueSeverity.ERROR

    def test_explicit_specific_path_in_plugin_dir_overlaps(self) -> None:
        """A core glob that explicitly names a path inside a plugin dir
        is still an overlap (the explicit-override escape hatch is a
        plugin-side concept; core specifying plugin paths is the smell)."""
        from claw_forge.spec.validator import check_touches_overlap

        spec = self._spec([
            self._plugin("billing"),
            self._core("src/plugins/billing/auth.py"),
        ])
        issues = check_touches_overlap(spec, strict=False)
        assert len(issues) == 1

    def test_multiple_overlaps_emit_one_per_pair(self) -> None:
        from claw_forge.spec.validator import check_touches_overlap

        spec = self._spec([
            self._plugin("auth"),
            self._plugin("billing"),
            self._core("src/**"),
        ])
        issues = check_touches_overlap(spec, strict=False)
        assert len(issues) == 2

    def test_plugin_with_no_plugin_attr_skipped(self) -> None:
        """Plugin features without plugin= can't have a derivable directory;
        skip — Gap 1 will have already flagged them."""
        from claw_forge.spec.validator import check_touches_overlap

        feat = FeatureItem(
            category="Auth", name="x", description="y",
            shape="plugin", plugin=None, touches_files=["src/somewhere/x.py"],
        )
        spec = self._spec([feat, self._core("src/**")])
        assert check_touches_overlap(spec, strict=False) == []

    def test_legacy_unshaped_features_ignored(self) -> None:
        """No shape annotations → no overlap analysis."""
        from claw_forge.spec.validator import check_touches_overlap

        feats = [
            FeatureItem(category="x", name="a", description="a", touches_files=["src/**"]),
            FeatureItem(category="x", name="b", description="b", touches_files=["src/plugins/auth/**"]),
        ]
        assert check_touches_overlap(self._spec(feats), strict=False) == []
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `uv run pytest tests/spec/test_validator_shape.py::TestCheckTouchesOverlap -v`

Expected: ImportError on `check_touches_overlap`.

- [ ] **Step 3: Implement the check**

Append to `claw_forge/spec/validator.py`:

```python
def check_touches_overlap(
    spec: ProjectSpec, *, strict: bool,
) -> list[ValidationIssue]:
    """Gap 3 — core feature's touches_files glob overlaps a plugin directory.

    Conservative algorithm: for each shape='plugin' feature with a plugin=
    attribute, derive the plugin root path 'src/plugins/<name>/' and probe
    each shape='core' feature's touches_files globs with fnmatch against
    a representative path under that root.  Catches sloppy core globs
    ('src/**') and explicit core paths inside plugin directories.
    Misses subtle overlaps (e.g. core globs that match siblings of the
    plugin root) — false-negatives over false-positives by design.

    WARNING by default; ERROR in strict mode.
    """
    import fnmatch

    from claw_forge.spec.parser import DEFAULT_PLUGIN_ROOT

    severity = IssueSeverity.ERROR if strict else IssueSeverity.WARNING
    issues: list[ValidationIssue] = []

    plugin_feats = [
        f for f in spec.features
        if f.shape == "plugin" and f.plugin
    ]
    core_feats = [f for f in spec.features if f.shape == "core"]

    for plugin_feat in plugin_feats:
        plugin_root = f"{DEFAULT_PLUGIN_ROOT}/{plugin_feat.plugin}/"
        probe = f"{plugin_root}__placeholder__.py"
        for core_feat in core_feats:
            for glob in core_feat.touches_files:
                if fnmatch.fnmatchcase(probe, glob):
                    issues.append(
                        ValidationIssue(
                            severity=severity,
                            layer=4,
                            category=core_feat.category,
                            bullet=core_feat.description,
                            message=(
                                f"core feature's touches_files glob {glob!r} "
                                f"overlaps {plugin_root}"
                            ),
                            suggestion=(
                                f"Narrow the core glob to exclude {plugin_root}, "
                                f"or re-shape the file ownership."
                            ),
                        )
                    )
                    break  # one issue per (plugin, core) pair, not per glob
    return issues
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `uv run pytest tests/spec/test_validator_shape.py::TestCheckTouchesOverlap -v`

Expected: 7 PASS.

- [ ] **Step 5: Commit**

```bash
git add claw_forge/spec/validator.py tests/spec/test_validator_shape.py
git commit -m "$(cat <<'EOF'
feat(validator): Gap 3 check — touches_files overlap between core and plugin

fnmatch-based glob probe against representative path under each plugin
root. Catches sloppy core globs and explicit core paths inside plugin
dirs. Conservative — false-negatives over false-positives.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 5: Validator — `check_core_chain_depth` (Gap 7)

**Files:**
- Modify: `claw_forge/spec/validator.py` (append)
- Test: `tests/spec/test_validator_shape.py` (append)

- [ ] **Step 1: Write failing tests**

Append to `tests/spec/test_validator_shape.py`:

```python
class TestCheckCoreChainDepth:
    """Gap 7 — long core-on-core depends_on chain (depth ≥
    CORE_CHAIN_DEPTH_THRESHOLD). Always WARNING — architectural smell,
    not invariant. Strict flag does not promote."""

    def _core(self, idx: int, deps: list[int] | None = None) -> FeatureItem:
        return FeatureItem(
            category="Core",
            name=f"core-{idx}",
            description=f"System enforces invariant {idx}",
            shape="core",
            touches_files=[f"src/core/inv_{idx}.py"],
            index=idx,
            depends_on_indices=deps or [],
        )

    def _plugin(self, idx: int, name: str, deps: list[int] | None = None) -> FeatureItem:
        return FeatureItem(
            category="Auth",
            name=f"plug-{idx}",
            description=f"User can do {name}",
            shape="plugin",
            plugin=name,
            touches_files=[f"src/plugins/{name}/**"],
            index=idx,
            depends_on_indices=deps or [],
        )

    def _spec(self, features: list[FeatureItem]) -> ProjectSpec:
        from claw_forge.spec.parser import TechStack

        return ProjectSpec(
            project_name="t", overview="o", tech_stack=TechStack(),
            features=features, implementation_phases=[], success_criteria=[],
        )

    def test_short_core_chain_passes(self) -> None:
        from claw_forge.spec.validator import check_core_chain_depth

        # 1 → 2 → 3 (length 3, threshold 4)
        spec = self._spec([
            self._core(1),
            self._core(2, deps=[1]),
            self._core(3, deps=[2]),
        ])
        assert check_core_chain_depth(spec) == []

    def test_long_core_chain_flagged(self) -> None:
        from claw_forge.spec.validator import check_core_chain_depth

        # 1 → 2 → 3 → 4 → 5 (length 5)
        spec = self._spec([
            self._core(1),
            self._core(2, deps=[1]),
            self._core(3, deps=[2]),
            self._core(4, deps=[3]),
            self._core(5, deps=[4]),
        ])
        issues = check_core_chain_depth(spec)
        assert len(issues) == 1
        assert issues[0].severity == IssueSeverity.WARNING
        assert issues[0].layer == 4
        assert "5" in issues[0].message  # chain length

    def test_plugin_break_resets_chain(self) -> None:
        """A plugin in the dependency path breaks the all-core chain."""
        from claw_forge.spec.validator import check_core_chain_depth

        # core1 → plugin2 → core3 → core4 → core5 — longest core run is 3
        spec = self._spec([
            self._core(1),
            self._plugin(2, "auth", deps=[1]),
            self._core(3, deps=[2]),
            self._core(4, deps=[3]),
            self._core(5, deps=[4]),
        ])
        assert check_core_chain_depth(spec) == []

    def test_diamond_pattern_uses_longest_path(self) -> None:
        from claw_forge.spec.validator import check_core_chain_depth

        # 1 → 2 → 4 → 5
        # 1 → 3 → 4 → 5  (4 has deps [2, 3]; longest path 4)
        spec = self._spec([
            self._core(1),
            self._core(2, deps=[1]),
            self._core(3, deps=[1]),
            self._core(4, deps=[2, 3]),
            self._core(5, deps=[4]),  # length: 1→2→4→5 or 1→3→4→5 = 4
        ])
        issues = check_core_chain_depth(spec)
        assert len(issues) == 1

    def test_strict_does_not_promote(self) -> None:
        """Gap 7 is always WARNING — architectural smell."""
        from claw_forge.spec.validator import check_core_chain_depth

        spec = self._spec([
            self._core(1),
            self._core(2, deps=[1]),
            self._core(3, deps=[2]),
            self._core(4, deps=[3]),
            self._core(5, deps=[4]),
        ])
        issues = check_core_chain_depth(spec)
        assert all(i.severity == IssueSeverity.WARNING for i in issues)

    def test_no_index_features_ignored(self) -> None:
        """Legacy bullets without index= can't participate in chain analysis."""
        from claw_forge.spec.validator import check_core_chain_depth

        feats = [
            FeatureItem(
                category="Core", name=f"x{i}", description="y",
                shape="core", touches_files=["src/x.py"],
                index=None,
            )
            for i in range(5)
        ]
        assert check_core_chain_depth(self._spec(feats)) == []
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `uv run pytest tests/spec/test_validator_shape.py::TestCheckCoreChainDepth -v`

Expected: ImportError on `check_core_chain_depth`.

- [ ] **Step 3: Implement the check**

Append to `claw_forge/spec/validator.py`:

```python
def check_core_chain_depth(spec: ProjectSpec) -> list[ValidationIssue]:
    """Gap 7 — depends_on chain of shape='core' features ≥ threshold.

    Computes longest path through the core-only subgraph using DP over
    a topological order (FeatureItem.index is 1-based positional).
    Skips legacy features without index=.  One WARNING per long chain
    root (deduplicated by chain endpoint).  Always WARNING.
    """
    # Build index → feature map; skip features without index
    by_index: dict[int, FeatureItem] = {
        f.index: f for f in spec.features if f.index is not None
    }

    # Longest path ending at each core node, restricted to core-only chains
    longest: dict[int, int] = {}
    for idx in sorted(by_index):
        feat = by_index[idx]
        if feat.shape != "core":
            longest[idx] = 0
            continue
        # Path length = 1 (this node) + max core-predecessor path length
        max_pred = 0
        for dep_idx in feat.depends_on_indices:
            pred = by_index.get(dep_idx)
            if pred is not None and pred.shape == "core":
                max_pred = max(max_pred, longest.get(dep_idx, 0))
        longest[idx] = 1 + max_pred

    # Find chain endpoints: nodes whose longest core chain ≥ threshold AND
    # no successor extends them further (so we report each chain once).
    successors: dict[int, list[int]] = {idx: [] for idx in by_index}
    for idx, feat in by_index.items():
        for dep_idx in feat.depends_on_indices:
            if dep_idx in successors:
                successors[dep_idx].append(idx)

    issues: list[ValidationIssue] = []
    for idx, length in longest.items():
        if length < CORE_CHAIN_DEPTH_THRESHOLD:
            continue
        feat = by_index[idx]
        if feat.shape != "core":
            continue
        # Only emit if no successor has a longer or equal chain through us
        extends_further = any(
            longest.get(s, 0) > length and by_index[s].shape == "core"
            for s in successors[idx]
        )
        if extends_further:
            continue
        issues.append(
            ValidationIssue(
                severity=IssueSeverity.WARNING,
                layer=4,
                category=feat.category,
                bullet=feat.description,
                message=(
                    f"shape='core' depends_on chain of length {length} "
                    f"ending at feature index {idx} — "
                    f"all {length} features serialize at dispatch time."
                ),
                suggestion=(
                    "Consider re-shaping intermediate links as plugin-shape "
                    "features (isolate their files to src/plugins/<name>/)."
                ),
            )
        )
    return issues
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `uv run pytest tests/spec/test_validator_shape.py::TestCheckCoreChainDepth -v`

Expected: 6 PASS.

- [ ] **Step 5: Commit**

```bash
git add claw_forge/spec/validator.py tests/spec/test_validator_shape.py
git commit -m "$(cat <<'EOF'
feat(validator): Gap 7 check — long core-on-core dependency chains

DP over topological order through the core-only subgraph; emits one
WARNING per chain endpoint. Architectural smell, not invariant —
strict mode does not promote.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 6: Validator — `check_category_looks_vertical` (Gap 6)

**Files:**
- Modify: `claw_forge/spec/validator.py` (append)
- Test: `tests/spec/test_validator_shape.py` (append)

- [ ] **Step 1: Write failing tests with boundary cases**

Append to `tests/spec/test_validator_shape.py`:

```python
class TestCheckCategoryLooksVertical:
    """Gap 6 — vertical-looking category with no shape declared.
    Operates on the unannotated subset only. WARNING / ERROR strict."""

    def _vert(self, n: int) -> list[FeatureItem]:
        """n unannotated bullets all matching VERTICAL_INDICATORS."""
        return [
            FeatureItem(
                category="Auth", name=f"v{i}",
                description=f"User can perform action {i}",
            )
            for i in range(n)
        ]

    def test_below_min_bullets_skipped(self) -> None:
        """|U| < VERTICAL_MIN_BULLETS → skip."""
        from claw_forge.spec.validator import check_category_looks_vertical

        feats = self._vert(VERTICAL_MIN_BULLETS - 1)
        assert check_category_looks_vertical("Auth", feats, strict=False) is None

    def test_min_bullets_all_vertical_flagged(self) -> None:
        from claw_forge.spec.validator import check_category_looks_vertical

        feats = self._vert(VERTICAL_MIN_BULLETS)
        issue = check_category_looks_vertical("Auth", feats, strict=False)
        assert issue is not None
        assert issue.severity == IssueSeverity.WARNING
        assert issue.layer == 4
        assert "auth" in issue.suggestion.lower()  # slug name appears

    def test_strict_promotes_to_error(self) -> None:
        from claw_forge.spec.validator import check_category_looks_vertical

        feats = self._vert(VERTICAL_MIN_BULLETS)
        issue = check_category_looks_vertical("Auth", feats, strict=True)
        assert issue is not None
        assert issue.severity == IssueSeverity.ERROR

    def test_at_threshold_flagged(self) -> None:
        """At exactly VERTICAL_RATIO_THRESHOLD → flag (≥ comparison)."""
        from claw_forge.spec.validator import check_category_looks_vertical

        # 5 unannotated, 3 vertical, 2 cross-cutting → ratio 1/5 = 0.2 (no)
        # Use 5 features: 3 vertical (+3), 0 cross-cutting → 3/5 = 0.6 → flag
        feats = [
            FeatureItem(category="Auth", name=f"v{i}",
                        description=f"User can do action {i}")
            for i in range(3)
        ] + [
            FeatureItem(category="Auth", name=f"n{i}",
                        description=f"Something happens {i}")
            for i in range(2)
        ]
        issue = check_category_looks_vertical("Auth", feats, strict=False)
        assert issue is not None

    def test_below_threshold_not_flagged(self) -> None:
        """Below VERTICAL_RATIO_THRESHOLD → skip."""
        from claw_forge.spec.validator import check_category_looks_vertical

        # 5 unannotated, 2 vertical, 0 cross-cutting → 2/5 = 0.4 (< 0.6)
        feats = [
            FeatureItem(category="Auth", name=f"v{i}",
                        description=f"User can do action {i}")
            for i in range(2)
        ] + [
            FeatureItem(category="Auth", name=f"n{i}",
                        description=f"Something happens {i}")
            for i in range(3)
        ]
        assert check_category_looks_vertical("Auth", feats, strict=False) is None

    def test_cross_cutting_negates_vertical(self) -> None:
        """Cross-cutting indicators score -1 and pull the ratio down."""
        from claw_forge.spec.validator import check_category_looks_vertical

        feats = [
            FeatureItem(category="Auth", name="v",
                        description="User can register"),                  # +1
            FeatureItem(category="Auth", name="x",
                        description="System enforces middleware globally"),  # -1 (×2 indicators, capped at -1)
            FeatureItem(category="Auth", name="y",
                        description="System tracks metrics"),              # -1
        ]
        # score = 1 - 1 - 1 = -1; ratio = -1/3 ≈ -0.33 → not flagged
        assert check_category_looks_vertical("Auth", feats, strict=False) is None

    def test_already_annotated_features_excluded_from_score(self) -> None:
        """Score is computed only on the unannotated subset."""
        from claw_forge.spec.validator import check_category_looks_vertical

        feats = [
            # 2 already-annotated (excluded from analysis)
            FeatureItem(category="Auth", name="a1", description="x",
                        shape="plugin", plugin="auth"),
            FeatureItem(category="Auth", name="a2", description="x",
                        shape="plugin", plugin="auth"),
            # 3 unannotated, all vertical
            FeatureItem(category="Auth", name="u1",
                        description="User can do thing"),
            FeatureItem(category="Auth", name="u2",
                        description="User can do other thing"),
            FeatureItem(category="Auth", name="u3",
                        description="User can do third thing"),
        ]
        issue = check_category_looks_vertical("Auth", feats, strict=False)
        assert issue is not None  # flagged because U is 3 vertical / 3 = 1.0

    def test_all_annotated_skipped(self) -> None:
        """|U| == 0 → skip."""
        from claw_forge.spec.validator import check_category_looks_vertical

        feats = [
            FeatureItem(category="Auth", name=f"a{i}", description="x",
                        shape="plugin", plugin="auth")
            for i in range(VERTICAL_MIN_BULLETS)
        ]
        assert check_category_looks_vertical("Auth", feats, strict=False) is None
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `uv run pytest tests/spec/test_validator_shape.py::TestCheckCategoryLooksVertical -v`

Expected: ImportError on `check_category_looks_vertical`.

- [ ] **Step 3: Implement the check**

Append to `claw_forge/spec/validator.py`:

```python
def check_category_looks_vertical(
    category: str, feats: list[FeatureItem], *, strict: bool,
) -> ValidationIssue | None:
    """Gap 6 — category bullets look vertical but no shape declared.

    Operates on the unannotated subset U = {f : f.shape is None}.
    Score each bullet: +1 for any VERTICAL_INDICATORS match, -1 for
    any CROSS_CUTTING_INDICATORS match (capped at ±1 per bullet).
    Flag when |U| ≥ VERTICAL_MIN_BULLETS and (score / |U|) ≥
    VERTICAL_RATIO_THRESHOLD.

    WARNING / ERROR strict.
    """
    severity = IssueSeverity.ERROR if strict else IssueSeverity.WARNING
    unannotated = [f for f in feats if f.shape is None]
    if len(unannotated) < VERTICAL_MIN_BULLETS:
        return None

    score = 0
    for feat in unannotated:
        text = feat.description.lower()
        is_vertical = any(tok in text for tok in VERTICAL_INDICATORS)
        is_cross = any(tok in text for tok in CROSS_CUTTING_INDICATORS)
        if is_vertical and not is_cross:
            score += 1
        elif is_cross and not is_vertical:
            score -= 1
        # mixed or neither → 0

    ratio = score / len(unannotated)
    if ratio < VERTICAL_RATIO_THRESHOLD:
        return None

    suggested_slug = _slugify_plugin_name(category) or "<plugin-name>"
    return ValidationIssue(
        severity=severity,
        layer=4,
        category=category,
        bullet=unannotated[0].description,
        message=(
            f"{len(unannotated)} unannotated bullets in '{category}' look "
            f"vertical (vertical-ratio {ratio:.2f}) but no shape declared."
        ),
        suggestion=(
            f"Convert each unannotated bullet to "
            f"<feature shape=\"plugin\" plugin=\"{suggested_slug}\">."
        ),
    )
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `uv run pytest tests/spec/test_validator_shape.py::TestCheckCategoryLooksVertical -v`

Expected: 8 PASS.

- [ ] **Step 5: Commit**

```bash
git add claw_forge/spec/validator.py tests/spec/test_validator_shape.py
git commit -m "$(cat <<'EOF'
feat(validator): Gap 6 check — vertical category heuristic

Score bullets in the unannotated subset against VERTICAL_INDICATORS /
CROSS_CUTTING_INDICATORS vocabularies. Flag at ratio ≥ 0.6 with ≥3
unannotated bullets. WARNING by default; ERROR in strict mode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 7: Validator — `check_plugin_dir_exists` (Gap 5)

**Files:**
- Modify: `claw_forge/spec/validator.py` (append)
- Test: `tests/spec/test_validator_shape.py` (append)

- [ ] **Step 1: Write failing tests using `tmp_path` fixture**

Append to `tests/spec/test_validator_shape.py`:

```python
class TestCheckPluginDirExists:
    """Gap 5 — plugin='X' references nonexistent directory.
    Brownfield-only: skip if project_root has no src/plugins/.
    WARNING / ERROR strict."""

    def _feat(self, plugin: str) -> FeatureItem:
        return FeatureItem(
            category="Auth", name="x",
            description="User can register",
            shape="plugin", plugin=plugin,
            touches_files=[f"src/plugins/{plugin}/**"],
        )

    def test_greenfield_skipped(self, tmp_path: Path) -> None:
        """No src/plugins/ → skip silently."""
        from claw_forge.spec.validator import check_plugin_dir_exists

        feat = self._feat("auth")
        result = check_plugin_dir_exists(
            feat, project_root=tmp_path, strict=False, brownfield=False,
        )
        assert result is None

    def test_brownfield_present_dir_passes(self, tmp_path: Path) -> None:
        from claw_forge.spec.validator import check_plugin_dir_exists

        (tmp_path / "src" / "plugins" / "auth").mkdir(parents=True)
        feat = self._feat("auth")
        result = check_plugin_dir_exists(
            feat, project_root=tmp_path, strict=False, brownfield=True,
        )
        assert result is None

    def test_brownfield_missing_dir_warns(self, tmp_path: Path) -> None:
        from claw_forge.spec.validator import check_plugin_dir_exists

        (tmp_path / "src" / "plugins" / "auth").mkdir(parents=True)
        # Other plugins exist but not "profile"
        feat = self._feat("profile")
        issue = check_plugin_dir_exists(
            feat, project_root=tmp_path, strict=False, brownfield=True,
        )
        assert issue is not None
        assert issue.severity == IssueSeverity.WARNING
        assert issue.layer == 4
        assert "profile" in issue.message

    def test_brownfield_missing_dir_strict_errors(self, tmp_path: Path) -> None:
        from claw_forge.spec.validator import check_plugin_dir_exists

        (tmp_path / "src" / "plugins" / "auth").mkdir(parents=True)
        feat = self._feat("profile")
        issue = check_plugin_dir_exists(
            feat, project_root=tmp_path, strict=True, brownfield=True,
        )
        assert issue is not None
        assert issue.severity == IssueSeverity.ERROR

    def test_non_plugin_shape_skipped(self, tmp_path: Path) -> None:
        from claw_forge.spec.validator import check_plugin_dir_exists

        (tmp_path / "src" / "plugins" / "auth").mkdir(parents=True)
        feat = FeatureItem(
            category="Core", name="x", description="y",
            shape="core", touches_files=["src/middleware.py"],
        )
        assert check_plugin_dir_exists(
            feat, project_root=tmp_path, strict=False, brownfield=True,
        ) is None

    def test_plugin_without_plugin_attr_skipped(self, tmp_path: Path) -> None:
        """Gap 1 already flags this; Gap 5 skips."""
        from claw_forge.spec.validator import check_plugin_dir_exists

        (tmp_path / "src" / "plugins" / "auth").mkdir(parents=True)
        feat = FeatureItem(
            category="Auth", name="x", description="y",
            shape="plugin", plugin=None, touches_files=["src/some/path.py"],
        )
        assert check_plugin_dir_exists(
            feat, project_root=tmp_path, strict=False, brownfield=True,
        ) is None
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `uv run pytest tests/spec/test_validator_shape.py::TestCheckPluginDirExists -v`

Expected: ImportError on `check_plugin_dir_exists`.

- [ ] **Step 3: Implement the check**

Append to `claw_forge/spec/validator.py`:

```python
def check_plugin_dir_exists(
    feat: FeatureItem,
    project_root: Path,
    *,
    strict: bool,
    brownfield: bool,
) -> ValidationIssue | None:
    """Gap 5 — plugin='X' must reference an existing directory.

    Brownfield-only: greenfield specs predate the directory structure,
    so flagging would be noise.  WARNING / ERROR strict.
    """
    from claw_forge.spec.parser import DEFAULT_PLUGIN_ROOT

    if not brownfield:
        return None
    if feat.shape != "plugin" or not feat.plugin:
        return None

    severity = IssueSeverity.ERROR if strict else IssueSeverity.WARNING
    plugin_dir = project_root / DEFAULT_PLUGIN_ROOT / feat.plugin
    if plugin_dir.is_dir():
        return None
    return ValidationIssue(
        severity=severity,
        layer=4,
        category=feat.category,
        bullet=feat.description,
        message=(
            f"plugin='{feat.plugin}' references nonexistent directory "
            f"{DEFAULT_PLUGIN_ROOT}/{feat.plugin}/ — typo or missing scaffold?"
        ),
        suggestion=(
            f"Rename plugin= to an existing directory, override with explicit "
            f"touches_files=, or run `claw-forge boundaries apply` "
            f"to create {DEFAULT_PLUGIN_ROOT}/{feat.plugin}/."
        ),
    )
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `uv run pytest tests/spec/test_validator_shape.py::TestCheckPluginDirExists -v`

Expected: 6 PASS.

- [ ] **Step 5: Commit**

```bash
git add claw_forge/spec/validator.py tests/spec/test_validator_shape.py
git commit -m "$(cat <<'EOF'
feat(validator): Gap 5 check — plugin directory existence (brownfield)

Filesystem check: plugin='X' must reference an existing directory under
src/plugins/. Brownfield-only (skips greenfield to avoid noise).
WARNING by default; ERROR in strict mode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 8: Validator — `run_shape_checks` entrypoint

**Files:**
- Modify: `claw_forge/spec/validator.py` (append)
- Test: `tests/spec/test_validator_shape.py` (append)

- [ ] **Step 1: Write failing tests for the entrypoint**

Append to `tests/spec/test_validator_shape.py`:

```python
class TestRunShapeChecks:
    """run_shape_checks composes the five check functions and returns
    a ValidationReport."""

    def _spec(self, features: list[FeatureItem]) -> ProjectSpec:
        from claw_forge.spec.parser import TechStack

        return ProjectSpec(
            project_name="t", overview="o", tech_stack=TechStack(),
            features=features, implementation_phases=[], success_criteria=[],
        )

    def test_clean_spec_zero_issues(self) -> None:
        from claw_forge.spec.validator import run_shape_checks

        spec = self._spec([
            FeatureItem(
                category="Auth", name="register",
                description="User can register",
                shape="plugin", plugin="auth",
                touches_files=["src/plugins/auth/**"],
            ),
        ])
        report = run_shape_checks(spec, project_root=None, strict=False)
        assert report.issues == []

    def test_collects_gap_1_error(self) -> None:
        from claw_forge.spec.validator import run_shape_checks

        spec = self._spec([
            FeatureItem(
                category="Auth", name="register",
                description="User can register",
                shape="plugin",  # missing plugin= and touches_files=
            ),
        ])
        report = run_shape_checks(spec, project_root=None, strict=False)
        assert any(
            i.severity == IssueSeverity.ERROR and i.layer == 4
            for i in report.issues
        )

    def test_filesystem_check_skipped_without_project_root(
        self, tmp_path: Path
    ) -> None:
        """No project_root → check_plugin_dir_exists not run."""
        from claw_forge.spec.validator import run_shape_checks

        spec = self._spec([
            FeatureItem(
                category="Auth", name="x", description="User can do x",
                shape="plugin", plugin="ghost",  # no dir, but no project_root
                touches_files=["src/plugins/ghost/**"],
            ),
        ])
        report = run_shape_checks(spec, project_root=None, strict=False)
        # No filesystem-related issues
        assert not any(
            "directory" in i.message.lower() for i in report.issues
        )

    def test_filesystem_check_runs_with_brownfield_root(
        self, tmp_path: Path
    ) -> None:
        from claw_forge.spec.validator import run_shape_checks

        # Brownfield: src/plugins/ exists with some plugin
        (tmp_path / "src" / "plugins" / "real").mkdir(parents=True)
        spec = self._spec([
            FeatureItem(
                category="Auth", name="x", description="User can do x",
                shape="plugin", plugin="ghost",  # missing dir
                touches_files=["src/plugins/ghost/**"],
            ),
        ])
        report = run_shape_checks(
            spec, project_root=tmp_path, strict=False,
        )
        assert any(
            "directory" in i.message.lower() and i.layer == 4
            for i in report.issues
        )

    def test_strict_promotes_warnings_to_errors(self, tmp_path: Path) -> None:
        from claw_forge.spec.validator import run_shape_checks

        (tmp_path / "src" / "plugins" / "real").mkdir(parents=True)
        spec = self._spec([
            FeatureItem(
                category="Auth", name="x", description="User can do x",
                shape="plugin", plugin="ghost",
                touches_files=["src/plugins/ghost/**"],
            ),
        ])
        report_default = run_shape_checks(
            spec, project_root=tmp_path, strict=False,
        )
        report_strict = run_shape_checks(
            spec, project_root=tmp_path, strict=True,
        )
        default_severities = {i.severity for i in report_default.issues}
        strict_severities = {i.severity for i in report_strict.issues}
        assert IssueSeverity.WARNING in default_severities
        assert IssueSeverity.ERROR in strict_severities
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `uv run pytest tests/spec/test_validator_shape.py::TestRunShapeChecks -v`

Expected: ImportError on `run_shape_checks`.

- [ ] **Step 3: Implement the entrypoint**

Append to `claw_forge/spec/validator.py`:

```python
def run_shape_checks(
    spec: ProjectSpec,
    *,
    project_root: Path | None = None,
    strict: bool = False,
) -> ValidationReport:
    """Layer 4: architectural-shape coherence.

    Composes the five check functions:
      - check_plugin_shape_complete (Gap 1; per-feature; always ERROR)
      - check_touches_overlap (Gap 3; cross-feature; WARN/ERR strict)
      - check_core_chain_depth (Gap 7; graph; always WARN)
      - check_category_looks_vertical (Gap 6; per-category heuristic; WARN/ERR strict)
      - check_plugin_dir_exists (Gap 5; filesystem; brownfield only; WARN/ERR strict)

    Filesystem checks skip when project_root is None.  Brownfield is
    detected by the presence of src/plugins/ under project_root.
    """
    from claw_forge.spec.parser import DEFAULT_PLUGIN_ROOT

    issues: list[ValidationIssue] = []

    # Per-feature checks
    for feat in spec.features:
        issue = check_plugin_shape_complete(feat, strict=strict)
        if issue is not None:
            issues.append(issue)

    # Cross-feature
    issues.extend(check_touches_overlap(spec, strict=strict))
    issues.extend(check_core_chain_depth(spec))

    # Per-category heuristic
    by_category: dict[str, list[FeatureItem]] = {}
    for feat in spec.features:
        by_category.setdefault(feat.category, []).append(feat)
    for cat, feats in by_category.items():
        issue = check_category_looks_vertical(cat, feats, strict=strict)
        if issue is not None:
            issues.append(issue)

    # Filesystem (brownfield only)
    if project_root is not None:
        plugin_root_dir = project_root / DEFAULT_PLUGIN_ROOT
        brownfield = plugin_root_dir.is_dir()
        for feat in spec.features:
            issue = check_plugin_dir_exists(
                feat, project_root,
                strict=strict, brownfield=brownfield,
            )
            if issue is not None:
                issues.append(issue)

    return ValidationReport(issues=issues, category_scores={})
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `uv run pytest tests/spec/test_validator_shape.py::TestRunShapeChecks -v`

Expected: 5 PASS. Then run the full test_validator_shape.py to confirm no regressions:

Run: `uv run pytest tests/spec/test_validator_shape.py -v`

Expected: All 49 tests pass.

- [ ] **Step 5: Commit**

```bash
git add claw_forge/spec/validator.py tests/spec/test_validator_shape.py
git commit -m "$(cat <<'EOF'
feat(validator): run_shape_checks entrypoint composes Layer 4 checks

Single entrypoint that calls all five check functions, applies brownfield
detection for filesystem checks, and returns a ValidationReport. Used
by validate_spec() in the next task.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 9: Validator — `validate_spec` integration

**Files:**
- Modify: `claw_forge/spec/validator.py:614-650` (the existing `validate_spec` function)
- Test: `tests/spec/test_validator.py` (append integration tests)

- [ ] **Step 1: Write failing integration tests**

Append to `tests/spec/test_validator.py`:

```python
class TestValidateSpecLayer4Integration:
    """validate_spec() runs Layer 4 alongside L1-L3."""

    def _spec(self, features: list) -> "ProjectSpec":
        from claw_forge.spec.parser import ProjectSpec, TechStack

        return ProjectSpec(
            project_name="t", overview="o", tech_stack=TechStack(),
            features=features, implementation_phases=[], success_criteria=[],
        )

    def test_layer_4_issues_in_report(self) -> None:
        from claw_forge.spec.parser import FeatureItem
        from claw_forge.spec.validator import validate_spec

        spec = self._spec([
            FeatureItem(
                category="Auth", name="x",
                description="User can register",
                shape="plugin",  # gap 1: missing plugin and touches_files
            ),
        ])
        report = validate_spec(spec, run_llm=False)
        l4_issues = report.layer_issues(4)
        assert len(l4_issues) >= 1
        assert any(i.layer == 4 for i in l4_issues)

    def test_strict_shape_kwarg_promotes(self) -> None:
        from claw_forge.spec.parser import FeatureItem
        from claw_forge.spec.validator import IssueSeverity, validate_spec

        # Vertical category, no shape declared (Gap 6 heuristic — WARN/ERR strict)
        feats = [
            FeatureItem(category="Auth", name=f"v{i}",
                        description=f"User can do action {i}")
            for i in range(3)
        ]
        spec = self._spec(feats)
        report_default = validate_spec(spec, run_llm=False, strict_shape=False)
        report_strict = validate_spec(spec, run_llm=False, strict_shape=True)

        default_l4 = [i for i in report_default.layer_issues(4) if "vertical" in i.message.lower()]
        strict_l4 = [i for i in report_strict.layer_issues(4) if "vertical" in i.message.lower()]
        assert default_l4 and default_l4[0].severity == IssueSeverity.WARNING
        assert strict_l4 and strict_l4[0].severity == IssueSeverity.ERROR

    def test_project_root_kwarg_enables_filesystem_check(self, tmp_path) -> None:
        from claw_forge.spec.parser import FeatureItem
        from claw_forge.spec.validator import validate_spec

        (tmp_path / "src" / "plugins" / "real").mkdir(parents=True)
        spec = self._spec([
            FeatureItem(
                category="Auth", name="x", description="User can do x",
                shape="plugin", plugin="ghost",  # missing dir
                touches_files=["src/plugins/ghost/**"],
            ),
        ])

        # Without project_root: no filesystem issue
        report_no_root = validate_spec(spec, run_llm=False)
        assert not any(
            "directory" in i.message.lower() and i.layer == 4
            for i in report_no_root.issues
        )
        # With project_root: filesystem issue surfaces
        report_with_root = validate_spec(
            spec, run_llm=False, project_root=tmp_path,
        )
        assert any(
            "directory" in i.message.lower() and i.layer == 4
            for i in report_with_root.issues
        )

    def test_layer_4_does_not_break_other_layers(self) -> None:
        """L1-L3 still run when L4 emits issues."""
        from claw_forge.spec.parser import FeatureItem
        from claw_forge.spec.validator import validate_spec

        spec = self._spec([
            # Gap 1: shape='plugin' incomplete
            FeatureItem(category="Auth", name="x",
                        description="User can register and then login",  # also Layer 1 atomic violation
                        shape="plugin"),
        ])
        report = validate_spec(spec, run_llm=False)
        assert report.layer_issues(1)
        assert report.layer_issues(4)
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `uv run pytest tests/spec/test_validator.py::TestValidateSpecLayer4Integration -v`

Expected: TypeError or fail because `validate_spec` doesn't yet take `strict_shape` / `project_root` kwargs.

- [ ] **Step 3: Update `validate_spec` signature and add Layer 4 call**

Edit `claw_forge/spec/validator.py:614-650`. Replace the existing function:

```python
def validate_spec(
    spec: ProjectSpec,
    run_llm: bool = True,
    approve_threshold: float = 7.0,
    llm_model: str = "claude-haiku-4-5-20251001",
    *,
    strict_shape: bool = False,
    project_root: Path | None = None,
) -> ValidationReport:
    """Run all four validation layers and return a merged ValidationReport.

    Parameters
    ----------
    spec:
        Parsed ProjectSpec (from ProjectSpec.from_file()).
    run_llm:
        Whether to run Layer 2 adversarial LLM evaluation.
        Skipped automatically if ANTHROPIC_API_KEY is absent.
    approve_threshold:
        Minimum category score for Layer 2 APPROVE. Default: 7.0.
    llm_model:
        Model for Layer 2. Default: Haiku (fast + cheap).
    strict_shape:
        If True, promote Layer 4 WARNING issues for gaps 3, 5, 6 to ERROR.
        Gap 1 is always ERROR; Gap 7 is always WARNING (not promoted).
    project_root:
        Project root for Layer 4 filesystem checks (Gap 5).  If None,
        filesystem checks are skipped.
    """
    merged = ValidationReport(issues=[], category_scores={})

    # Layer 1 — structural checks
    l1 = run_structural_checks(spec)
    merged.issues.extend(l1.issues)

    # Layer 2 — adversarial LLM (optional)
    if run_llm:
        l2 = run_llm_evaluation(spec, approve_threshold=approve_threshold, model=llm_model)
        merged.issues.extend(l2.issues)
        merged.category_scores.update(l2.category_scores)

    # Layer 3 — coverage gaps
    l3 = run_coverage_checks(spec)
    merged.issues.extend(l3.issues)

    # Layer 4 — architectural-shape coherence
    l4 = run_shape_checks(
        spec, project_root=project_root, strict=strict_shape,
    )
    merged.issues.extend(l4.issues)

    return merged
```

Note: also ensure `from pathlib import Path` is imported at the top of `validator.py` (it isn't currently — the file doesn't use Path elsewhere). Add at the top of the file with the existing imports:

```python
from pathlib import Path
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `uv run pytest tests/spec/test_validator.py::TestValidateSpecLayer4Integration -v`

Expected: 4 PASS.

Then run the full validator test suite:

Run: `uv run pytest tests/spec/test_validator.py tests/spec/test_validator_shape.py -q`

Expected: All pass.

- [ ] **Step 5: Commit**

```bash
git add claw_forge/spec/validator.py tests/spec/test_validator.py
git commit -m "$(cat <<'EOF'
feat(validator): wire Layer 4 into validate_spec

validate_spec() gains strict_shape and project_root kwargs (keyword-only)
and calls run_shape_checks alongside the existing three layers. L1-L3
behavior unchanged; L4 issues are merged into the same report.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 10: CLI — `_resolve_project_root` helper

**Files:**
- Modify: `claw_forge/cli.py` (add helper near other private helpers — locate near `_resolve_latest_session`)
- Test: `tests/test_cli_validate_spec_strict_shape.py` (new file, helper tests inline)

- [ ] **Step 1: Locate the helpers section and create the test file**

Run: `grep -n "^def _resolve_latest_session\|^def _ensure_state_service" /Users/bowenli/development/claw-forge/claw_forge/cli.py`

Note the location — `_resolve_project_root` will sit alongside these private helpers.

Create `tests/test_cli_validate_spec_strict_shape.py`:

```python
"""CLI flag wiring for validate-spec's --strict-shape and --project-root.

Helper unit tests + Typer flag-propagation tests using CliRunner.
"""
from __future__ import annotations

from pathlib import Path

import pytest
from typer.testing import CliRunner

from claw_forge.cli import _resolve_project_root, app


runner = CliRunner()


class TestResolveProjectRoot:
    def test_explicit_override_wins(self, tmp_path: Path) -> None:
        spec = tmp_path / "spec.xml"
        spec.write_text("<x/>")
        override = tmp_path / "elsewhere"
        override.mkdir()
        assert _resolve_project_root(spec, override=override) == override

    def test_walks_up_to_find_claw_forge_yaml(self, tmp_path: Path) -> None:
        # tmp_path/proj/.claw-forge.yaml
        # tmp_path/proj/specs/spec.xml
        proj = tmp_path / "proj"
        (proj / "specs").mkdir(parents=True)
        (proj / ".claw-forge.yaml").write_text("")
        spec = proj / "specs" / "spec.xml"
        spec.write_text("<x/>")
        assert _resolve_project_root(spec, override=None) == proj

    def test_walks_up_to_find_dot_git(self, tmp_path: Path) -> None:
        proj = tmp_path / "proj"
        (proj / "deep" / "nested").mkdir(parents=True)
        (proj / ".git").mkdir()
        spec = proj / "deep" / "nested" / "spec.xml"
        spec.write_text("<x/>")
        assert _resolve_project_root(spec, override=None) == proj

    def test_claw_forge_yaml_wins_over_git(self, tmp_path: Path) -> None:
        # claw-forge.yaml is more specific than .git
        proj = tmp_path / "proj"
        (proj / "subdir").mkdir(parents=True)
        (proj / ".git").mkdir()
        (proj / "subdir" / ".claw-forge.yaml").write_text("")
        spec = proj / "subdir" / "spec.xml"
        spec.write_text("<x/>")
        assert _resolve_project_root(spec, override=None) == proj / "subdir"

    def test_returns_none_when_neither_marker_found(self, tmp_path: Path) -> None:
        # Make sure tmp_path itself has no markers (it's already isolated)
        spec = tmp_path / "spec.xml"
        spec.write_text("<x/>")
        # Walk should hit the filesystem root and return None
        assert _resolve_project_root(spec, override=None) is None
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `uv run pytest tests/test_cli_validate_spec_strict_shape.py::TestResolveProjectRoot -v`

Expected: ImportError on `_resolve_project_root`.

- [ ] **Step 3: Implement the helper**

Add to `claw_forge/cli.py`. Place it near the other underscore helpers (look for `_resolve_latest_session` and add nearby):

```python
def _resolve_project_root(
    spec_path: Path,
    *,
    override: Path | None = None,
) -> Path | None:
    """Resolve the project root for Layer 4 filesystem checks.

    Order:
      1. If override given, return it.
      2. Walk up from spec_path.parent looking for .claw-forge.yaml.
      3. If none found, walk up looking for .git.
      4. Otherwise return None — Layer 4 filesystem checks will be skipped.

    Returns the directory containing the marker, or None.
    """
    if override is not None:
        return override
    start = spec_path.resolve().parent
    # Pass 1: .claw-forge.yaml (more specific)
    for parent in [start, *start.parents]:
        if (parent / ".claw-forge.yaml").is_file():
            return parent
    # Pass 2: .git (looser — any git repo)
    for parent in [start, *start.parents]:
        if (parent / ".git").exists():
            return parent
    return None
```

The `.git` check uses `.exists()` (not `.is_dir()`) because git worktrees have `.git` as a file containing a gitdir reference.

- [ ] **Step 4: Run tests to verify they pass**

Run: `uv run pytest tests/test_cli_validate_spec_strict_shape.py::TestResolveProjectRoot -v`

Expected: 5 PASS.

- [ ] **Step 5: Commit**

```bash
git add claw_forge/cli.py tests/test_cli_validate_spec_strict_shape.py
git commit -m "$(cat <<'EOF'
feat(cli): _resolve_project_root helper for validate-spec Layer 4

Walks up from the spec file looking for .claw-forge.yaml first (most
specific), then .git (any git repo). Returns None if neither found —
Layer 4 filesystem checks will skip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 11: CLI — flags, header summary, and Layer 4 output block

**Files:**
- Modify: `claw_forge/cli.py:2559-2688` (the `validate_spec_cmd` function)
- Test: `tests/test_cli_validate_spec_strict_shape.py` (extend with CliRunner tests)

- [ ] **Step 1: Write failing CLI tests using CliRunner**

Append to `tests/test_cli_validate_spec_strict_shape.py`:

```python
class TestStrictShapeFlag:
    """--strict-shape promotes WARNINGs to ERRORs in Layer 4 output."""

    def _vertical_no_shape_spec(self, tmp_path: Path) -> Path:
        """Spec with a vertical-looking unannotated category — Gap 6."""
        xml = """<?xml version="1.0"?>
<project>
  <project_name>Test</project_name>
  <overview>x</overview>
  <tech_stack></tech_stack>
  <features>
    <category name="Authentication">
      <feature index="1"><description>User can register with email and password (returns 200)</description></feature>
      <feature index="2"><description>User can log in with credentials (returns 200 with JWT)</description></feature>
      <feature index="3"><description>User can log out (returns 204)</description></feature>
    </category>
  </features>
</project>"""
        spec = tmp_path / "spec.xml"
        spec.write_text(xml)
        return spec

    def test_default_mode_warnings(self, tmp_path: Path) -> None:
        spec = self._vertical_no_shape_spec(tmp_path)
        result = runner.invoke(app, ["validate-spec", str(spec), "--no-llm"])
        # Default mode: Layer 4 emits warnings, exit 0 (warnings don't fail)
        assert result.exit_code == 0
        assert "Layer 4 (Shape)" in result.stdout
        assert "warning" in result.stdout.lower()

    def test_strict_mode_promotes_to_errors(self, tmp_path: Path) -> None:
        spec = self._vertical_no_shape_spec(tmp_path)
        result = runner.invoke(
            app, ["validate-spec", str(spec), "--no-llm", "--strict-shape"],
        )
        assert result.exit_code == 1
        assert "Layer 4 (Shape)" in result.stdout
        assert "FAIL" in result.stdout

    def test_header_summary_includes_shape_count(self, tmp_path: Path) -> None:
        xml = """<?xml version="1.0"?>
<project>
  <project_name>Test</project_name>
  <overview>x</overview>
  <tech_stack></tech_stack>
  <features>
    <category name="Auth">
      <feature index="1" shape="plugin" plugin="auth"><description>User can register (returns 200)</description></feature>
      <feature index="2"><description>User can log in (returns 200)</description></feature>
    </category>
  </features>
</project>"""
        spec = tmp_path / "spec.xml"
        spec.write_text(xml)
        result = runner.invoke(app, ["validate-spec", str(spec), "--no-llm"])
        # Should show "Shape: 1 annotated, 1 unannotated" or similar
        assert "Shape:" in result.stdout
        assert "annotated" in result.stdout

    def test_project_root_override_flag(self, tmp_path: Path) -> None:
        # Create a brownfield root where plugin "auth" exists
        proj = tmp_path / "proj"
        (proj / "src" / "plugins" / "auth").mkdir(parents=True)
        spec_xml = """<?xml version="1.0"?>
<project>
  <project_name>Test</project_name>
  <overview>x</overview>
  <tech_stack></tech_stack>
  <features>
    <category name="Auth">
      <feature index="1" shape="plugin" plugin="ghost"><description>User can register (returns 200)</description></feature>
    </category>
  </features>
</project>"""
        spec = tmp_path / "spec.xml"
        spec.write_text(spec_xml)
        # Without --project-root, no filesystem check (no marker found)
        result_no_root = runner.invoke(
            app, ["validate-spec", str(spec), "--no-llm"],
        )
        # With --project-root pointing at proj, "ghost" plugin missing
        result_with_root = runner.invoke(
            app, ["validate-spec", str(spec), "--no-llm",
                  "--project-root", str(proj)],
        )
        assert "directory" not in result_no_root.stdout.lower() or \
               "skipped" in result_no_root.stdout.lower()
        assert "ghost" in result_with_root.stdout.lower()

    def test_unannotated_features_count_correct(self, tmp_path: Path) -> None:
        """Count is total features minus shape-set features."""
        xml = """<?xml version="1.0"?>
<project>
  <project_name>Test</project_name>
  <overview>x</overview>
  <tech_stack></tech_stack>
  <features>
    <category name="Auth">
      <feature index="1" shape="plugin" plugin="auth"><description>User can register (returns 200)</description></feature>
      <feature index="2" shape="plugin" plugin="auth"><description>User can log in (returns 200)</description></feature>
      <feature index="3"><description>User can log out (returns 204)</description></feature>
    </category>
  </features>
</project>"""
        spec = tmp_path / "spec.xml"
        spec.write_text(xml)
        result = runner.invoke(app, ["validate-spec", str(spec), "--no-llm"])
        assert "2 annotated, 1 unannotated" in result.stdout
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `uv run pytest tests/test_cli_validate_spec_strict_shape.py::TestStrictShapeFlag -v`

Expected: Failures because `--strict-shape` and `--project-root` are not yet flags, and the Layer 4 output block does not exist.

- [ ] **Step 3: Update `validate_spec_cmd`**

Edit `claw_forge/cli.py:2559-2688`. The full updated function (replacing the existing decorator + signature + body):

```python
@app.command("validate-spec")
def validate_spec_cmd(
    spec: str = typer.Argument(..., help="Path to app_spec.txt or additions_spec.xml."),
    no_llm: bool = typer.Option(
        False, "--no-llm",
        help="Skip Layer 2 adversarial LLM evaluation (Layer 1 + Layer 3 only).",
    ),
    threshold: float = typer.Option(
        7.0, "--threshold", "-t",
        help="Layer 2 LLM approval threshold (0-10). Default: 7.0.",
    ),
    model: str = typer.Option(
        "claude-haiku-4-5-20251001", "--model", "-m",
        help="Model for Layer 2 LLM evaluation.",
    ),
    strict_shape: bool = typer.Option(
        False, "--strict-shape",
        help="Promote shape WARNINGs (gaps 3, 5, 6) to ERRORs.",
    ),
    project_root: Path | None = typer.Option(
        None, "--project-root",
        help="Override auto-detected project root for filesystem-aware shape checks.",
    ),
) -> None:
    """Validate a spec file before planning.

    Runs 4 validation layers and exits non-zero if errors are found:

      Layer 1 — Structural: action verbs, measurable outcomes, atomicity, vagueness
      Layer 2 — LLM:        adversarial per-category scoring (requires ANTHROPIC_API_KEY)
      Layer 3 — Coverage:   cross-references endpoints and tables against bullets
      Layer 4 — Shape:      architectural-shape coherence (typos, plugin completeness,
                            core/plugin overlap, brownfield directory existence,
                            vertical-category nudges, long core chains)

    Examples:

        claw-forge validate-spec app_spec.txt
        claw-forge validate-spec app_spec.txt --no-llm
        claw-forge validate-spec app_spec.txt --threshold 8.0
        claw-forge validate-spec app_spec.txt --strict-shape
    """
    from claw_forge.spec.parser import ProjectSpec
    from claw_forge.spec.validator import IssueSeverity, validate_spec

    spec_path = Path(spec)
    if not spec_path.exists():
        console.print(f"[red]Spec file not found: {spec_path}[/red]")
        raise typer.Exit(1)

    try:
        parsed = ProjectSpec.from_file(spec_path)
    except Exception as exc:
        console.print(f"[red]Failed to parse spec: {exc}[/red]")
        raise typer.Exit(1) from exc

    resolved_project_root = _resolve_project_root(spec_path, override=project_root)

    shape_annotated = sum(1 for f in parsed.features if f.shape)
    shape_unannotated = len(parsed.features) - shape_annotated

    console.print(f"\n[bold]Validating:[/bold] {spec_path.name}")
    console.print(f"  Features:   {len(parsed.features)}")
    console.print(f"  Categories: {len({f.category for f in parsed.features})}")
    console.print(f"  Endpoints:  {sum(len(v) for v in parsed.api_endpoints.values())}")
    console.print(f"  Tables:     {len(parsed.database_tables)}")
    console.print(f"  Shape:      {shape_annotated} annotated, {shape_unannotated} unannotated")
    console.print()

    run_llm = not no_llm
    report = validate_spec(
        parsed,
        run_llm=run_llm,
        approve_threshold=threshold,
        llm_model=model,
        strict_shape=strict_shape,
        project_root=resolved_project_root,
    )

    # ── Layer 1 ──────────────────────────────────────────────────────────────
    l1 = report.layer_issues(1)
    l1_errors = [i for i in l1 if i.severity == IssueSeverity.ERROR]
    l1_warnings = [i for i in l1 if i.severity == IssueSeverity.WARNING]
    status_l1 = "[red]FAIL[/red]" if l1_errors else "[green]PASS[/green]"
    console.print(
        f"Layer 1 (Structural)  {status_l1}  "
        f"{len(l1_errors)} errors, {len(l1_warnings)} warnings"
    )
    for issue in l1_errors:
        console.print(f"  [red]✗[/red] [{issue.category}] {issue.message}")
        if issue.suggestion:
            console.print(f"    → {issue.suggestion}")
        console.print(f'    Bullet: "{issue.bullet[:80]}"')
    for issue in l1_warnings[:5]:
        console.print(f"  [yellow]⚠[/yellow] [{issue.category}] {issue.message}")
    if len(l1_warnings) > 5:
        console.print(f"  [dim]... and {len(l1_warnings) - 5} more warnings[/dim]")

    # ── Layer 2 ──────────────────────────────────────────────────────────────
    l2 = report.layer_issues(2)
    if not run_llm:
        console.print("\nLayer 2 (LLM eval)    [dim]skipped (--no-llm)[/dim]")
    elif any("skipped" in i.message.lower() or "ANTHROPIC_API_KEY" in i.message for i in l2):
        for issue in l2:
            console.print(f"\n  [dim]{issue.message}[/dim]")
    else:
        l2_fails = [i for i in l2 if i.category]
        status_l2 = "[red]FAIL[/red]" if l2_fails else "[green]PASS[/green]"
        console.print(f"\nLayer 2 (LLM eval)    {status_l2}")
        for cat, score in report.category_scores.items():
            mark = "[green]✓[/green]" if score >= threshold else "[yellow]⚠[/yellow]"
            console.print(f"  {mark} {cat}: {score:.1f}/10")
        for issue in l2_fails:
            console.print(f"  [yellow]⚠[/yellow] {issue.message}")

    # ── Layer 3 ──────────────────────────────────────────────────────────────
    l3 = report.layer_issues(3)
    status_l3 = "[yellow]GAPS[/yellow]" if l3 else "[green]PASS[/green]"
    console.print(f"\nLayer 3 (Coverage)    {status_l3}  {len(l3)} gaps")
    for issue in l3:
        console.print(f"  [yellow]⚠[/yellow] {issue.message}")
        if issue.suggestion:
            console.print(f"    → {issue.suggestion}")

    # ── Layer 4 ──────────────────────────────────────────────────────────────
    l4 = report.layer_issues(4)
    l4_errors = [i for i in l4 if i.severity == IssueSeverity.ERROR]
    l4_warnings = [i for i in l4 if i.severity == IssueSeverity.WARNING]
    if l4_errors:
        status_l4 = "[red]FAIL[/red]"
    elif l4_warnings:
        status_l4 = "[yellow]WARN[/yellow]"
    else:
        status_l4 = "[green]PASS[/green]"
    console.print(
        f"\nLayer 4 (Shape)       {status_l4}  "
        f"{len(l4_errors)} errors, {len(l4_warnings)} warnings"
    )
    if resolved_project_root is None:
        console.print(
            "  [dim]filesystem checks skipped "
            "(no project root detected — pass --project-root to enable)[/dim]"
        )
    for issue in l4_errors:
        console.print(f"  [red]✗[/red] [{issue.category}] {issue.message}")
        if issue.suggestion:
            console.print(f"    → {issue.suggestion}")
        if issue.bullet:
            console.print(f'    Bullet: "{issue.bullet[:80]}"')
    for issue in l4_warnings:
        console.print(f"  [yellow]⚠[/yellow] [{issue.category}] {issue.message}")
        if issue.suggestion:
            console.print(f"    → {issue.suggestion}")

    # ── Verdict ───────────────────────────────────────────────────────────────
    console.print()
    if report.passed:
        if report.warning_count:
            w = report.warning_count
            console.print(
                f"[bold yellow]⚠ Spec passed validation with {w} warning(s)[/bold yellow]"
            )
            console.print(
                f"\n  To clean up warnings: open Claude Code and run [bold]/fix-spec[/bold]\n"
                f"  Then re-run: [bold]claw-forge validate-spec {spec}[/bold]\n"
                f"\n  Or skip straight to: [bold]claw-forge plan {spec}[/bold]"
            )
        else:
            console.print("[bold green]✅ Spec passed validation — no issues[/bold green]")
            console.print(f"\n  Next: [bold]claw-forge plan {spec}[/bold]")
    else:
        console.print(
            f"[bold red]✗ Spec has {report.error_count} error(s) "
            f"— fix before running claw-forge plan[/bold red]"
        )
        console.print(
            f"\n  To fix: open Claude Code and run [bold]/fix-spec[/bold]\n"
            f"  Then re-run: [bold]claw-forge validate-spec {spec}[/bold]"
        )
        raise typer.Exit(1)
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `uv run pytest tests/test_cli_validate_spec_strict_shape.py -v`

Expected: All pass (5 helper tests + 5 flag tests = 10 PASS).

Then run a regression check on the existing CLI tests:

Run: `uv run pytest tests/test_cli_commands.py -q`

Expected: All pass (no regression).

- [ ] **Step 5: Commit**

```bash
git add claw_forge/cli.py tests/test_cli_validate_spec_strict_shape.py
git commit -m "$(cat <<'EOF'
feat(cli): validate-spec gains --strict-shape, --project-root, Layer 4 output

Two new Typer flags wire through to validate_spec(). Header summary
gains shape annotation count. Layer 4 output block mirrors L1's three-
state status (PASS / WARN / FAIL) and prints a dim skip-line when
filesystem checks couldn't run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 12: E2E tests with fixture specs

**Files:**
- Create: `tests/e2e/fixtures/specs_with_shape/clean.xml`
- Create: `tests/e2e/fixtures/specs_with_shape/mixed.xml`
- Create: `tests/e2e/fixtures/specs_with_shape/bad.xml`
- Modify: `tests/e2e/test_cli_e2e.py` (append new test class)

- [ ] **Step 1: Create the fixture specs**

Create `tests/e2e/fixtures/specs_with_shape/clean.xml`:

```xml
<?xml version="1.0"?>
<project>
  <project_name>CleanShape</project_name>
  <overview>Fully shape-annotated spec — no Layer 4 issues expected.</overview>
  <tech_stack></tech_stack>
  <features>
    <category name="Authentication">
      <feature index="1" shape="plugin" plugin="auth">
        <description>User can register with email and password (returns 200 with JWT)</description>
      </feature>
      <feature index="2" shape="plugin" plugin="auth">
        <description>User can log in with credentials (returns 200 with JWT)</description>
      </feature>
      <feature index="3" shape="plugin" plugin="auth">
        <description>User can log out (returns 204)</description>
      </feature>
    </category>
    <category name="Middleware">
      <feature index="4" shape="core" touches_files="src/core/middleware/jwt.py">
        <description>System validates JWT on every authenticated endpoint (returns 401 on missing token)</description>
      </feature>
    </category>
  </features>
</project>
```

Create `tests/e2e/fixtures/specs_with_shape/mixed.xml`:

```xml
<?xml version="1.0"?>
<project>
  <project_name>MixedShape</project_name>
  <overview>Mix of shape-annotated and vertical-looking unannotated.</overview>
  <tech_stack></tech_stack>
  <features>
    <category name="Authentication">
      <feature index="1" shape="plugin" plugin="auth">
        <description>User can register with email and password (returns 200)</description>
      </feature>
    </category>
    <category name="Profile">
      <feature index="2"><description>User can edit their profile name (returns 200 with updated profile)</description></feature>
      <feature index="3"><description>User can edit their profile avatar (returns 200 with avatar URL)</description></feature>
      <feature index="4"><description>User can delete their profile (returns 204)</description></feature>
    </category>
  </features>
</project>
```

Create `tests/e2e/fixtures/specs_with_shape/bad.xml`:

```xml
<?xml version="1.0"?>
<project>
  <project_name>BadShape</project_name>
  <overview>Gap 1 violation — shape='plugin' missing plugin= and touches_files=.</overview>
  <tech_stack></tech_stack>
  <features>
    <category name="Authentication">
      <feature index="1" shape="plugin">
        <description>User can register with email and password (returns 200)</description>
      </feature>
    </category>
  </features>
</project>
```

- [ ] **Step 2: Write failing e2e tests**

Append to `tests/e2e/test_cli_e2e.py`:

```python
class TestValidateSpecShape:
    """End-to-end coverage of validate-spec Layer 4 via real subprocess.

    Uses the file's existing _invoke() helper (see top of this file).
    Each fixture lives under tests/e2e/fixtures/specs_with_shape/.
    """

    FIXTURES = Path(__file__).parent / "fixtures" / "specs_with_shape"

    def test_clean_spec_passes(self) -> None:
        spec = self.FIXTURES / "clean.xml"
        proc = _invoke(["validate-spec", str(spec), "--no-llm"])
        assert proc.returncode == 0, proc.stdout + proc.stderr
        assert "Layer 4 (Shape)" in proc.stdout
        assert "PASS" in proc.stdout

    def test_mixed_spec_warns_in_default_mode(self) -> None:
        spec = self.FIXTURES / "mixed.xml"
        proc = _invoke(["validate-spec", str(spec), "--no-llm"])
        # Warnings present, exit 0 (warnings don't fail by default)
        assert proc.returncode == 0
        assert "Layer 4 (Shape)" in proc.stdout
        assert "WARN" in proc.stdout
        # Vertical-category nudge for Profile category
        assert "Profile" in proc.stdout or "profile" in proc.stdout

    def test_strict_shape_promotes_mixed_to_failure(self) -> None:
        spec = self.FIXTURES / "mixed.xml"
        # Default: warns, exit 0
        proc_default = _invoke(["validate-spec", str(spec), "--no-llm"])
        # Strict: same fixture, errors, exit 1
        proc_strict = _invoke(
            ["validate-spec", str(spec), "--no-llm", "--strict-shape"],
        )
        assert proc_default.returncode == 0
        assert proc_strict.returncode == 1
        assert "FAIL" in proc_strict.stdout

    def test_bad_spec_fails(self) -> None:
        spec = self.FIXTURES / "bad.xml"
        proc = _invoke(["validate-spec", str(spec), "--no-llm"])
        assert proc.returncode == 1
        assert "Layer 4 (Shape)" in proc.stdout
        assert "FAIL" in proc.stdout
        # Specific Gap 1 message hint
        assert "plugin=" in proc.stdout or "touches_files" in proc.stdout

    def test_header_summary_shape_count(self) -> None:
        spec = self.FIXTURES / "mixed.xml"
        proc = _invoke(["validate-spec", str(spec), "--no-llm"])
        # mixed.xml: 1 annotated, 3 unannotated
        assert "1 annotated, 3 unannotated" in proc.stdout
```

- [ ] **Step 3: Run tests to verify they fail**

Run: `uv run pytest tests/e2e/test_cli_e2e.py::TestValidateSpecShape -v`

Expected: Either ImportError (Path not imported) or fixture-related path errors. The shape behavior should already be wired from Tasks 1–11, so any failure here is e2e-plumbing not feature regression.

If `Path` is not imported in the test file, fix that:

Run: `grep -n "from pathlib import Path" /Users/bowenli/development/claw-forge/tests/e2e/test_cli_e2e.py || echo "needs Path import"`

If "needs Path import", add `from pathlib import Path` at the top of the file with the other imports.

- [ ] **Step 4: Run tests to verify they pass**

Run: `uv run pytest tests/e2e/test_cli_e2e.py::TestValidateSpecShape -v`

Expected: 5 PASS.

- [ ] **Step 5: Commit**

```bash
git add tests/e2e/fixtures/specs_with_shape tests/e2e/test_cli_e2e.py
git commit -m "$(cat <<'EOF'
test(e2e): validate-spec Layer 4 with three fixture specs

Real-subprocess invocations of clean/mixed/bad fixtures cover the full
spec → Layer 4 → exit-code path. Includes strict-mode promotion and
header summary count assertions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 13: `/fix-spec.md` extension

**Files:**
- Modify: `.claude/commands/fix-spec.md`

This task is prose-only (no code, no tests). One commit, three coordinated edits to the markdown file.

- [ ] **Step 1: Read the current file**

Run: `wc -l /Users/bowenli/development/claw-forge/.claude/commands/fix-spec.md`

Expected: ~166 lines.

- [ ] **Step 2: Insert new "Step 0: Handle parse-blocking errors" section**

Find the line `## Step 1: Find the spec file` (around line 7). Insert the new section BEFORE it.

Edit the file: change `## Step 1: Find the spec file` to:

```markdown
## Step 0: Handle parse-blocking errors

Run `claw-forge validate-spec <spec-file>` (after Step 1 finds the file). If
output starts with `Failed to parse spec:`, the spec has a parser-level
violation that must be fixed before any layer-based fixes can run.

**For unrecognized shape values** (e.g. `shape='ploogin'`):
1. Read the spec file. Find the `<feature shape='ploogin' ...>` element.
2. Compute Levenshtein distance from `'ploogin'` to `'plugin'` and `'core'`.
   - If best match is `'plugin'` with distance ≤ 2 → replace with `shape="plugin"`.
   - If best match is `'core'` with distance ≤ 2 → replace with `shape="core"`.
   - Otherwise → list under Manual Review (see Step 7).
3. Re-run validate-spec.

**For `<feature shape='core'>` missing `touches_files=`:**
This cannot be auto-fixed — `touches_files` requires domain knowledge of
which files the feature touches. List under Manual Review (see Step 7).

If parse-blocked after fixes, report the remaining error and stop.

## Step 1: Find the spec file
```

- [ ] **Step 3: Insert new "Layer 4 fixes" subsection in Step 4**

Find the line `### Layer 3 fixes (coverage gaps)` (around line 99). Insert the new subsection BEFORE it.

Edit the file: change `### Layer 3 fixes (coverage gaps)` to:

```markdown
### Layer 4 fixes (architectural shape)

| Issue | Severity | Rule |
|---|---|---|
| Gap 1: `shape="plugin"` missing `plugin=` and `touches_files=` | ERROR | Add `plugin="<category-slug>"` to the `<feature>` element. Slug: lowercase, replace non-alphanumerics with dashes, collapse repeats, strip leading/trailing dashes. |
| Gap 6: vertical category, no shape declared | WARNING / ERROR strict | Convert each unannotated bullet in the flagged category to `<feature shape="plugin" plugin="<category-slug>">…<description>{bullet text}</description></feature>`. Preserve the original bullet text verbatim inside `<description>`. |
| Gap 4 (typo): handled in Step 0 | (parse-time) | See Step 0. |

**Examples:**

```
# BEFORE (Gap 1 — ERROR):
<feature shape="plugin">
  <description>User can register with email and password</description>
</feature>

# AFTER (category was "Authentication"):
<feature shape="plugin" plugin="authentication">
  <description>User can register with email and password</description>
</feature>
```

```
# BEFORE (Gap 6 — WARNING; category "User Profile"):
<category name="User Profile">
  - User can edit their profile name (returns 200)
  - User can edit their profile avatar (returns 200)
  - User can delete their profile (returns 204)
</category>

# AFTER:
<category name="User Profile">
  <feature index="N" shape="plugin" plugin="user-profile">
    <description>User can edit their profile name (returns 200)</description>
  </feature>
  <feature index="N+1" shape="plugin" plugin="user-profile">
    <description>User can edit their profile avatar (returns 200)</description>
  </feature>
  <feature index="N+2" shape="plugin" plugin="user-profile">
    <description>User can delete their profile (returns 204)</description>
  </feature>
</category>
```

**Gaps that cannot be auto-fixed** (Gap 3 overlap, Gap 5 missing dir, Gap 7
long chain): list under Manual Review (see Step 7) — these require domain
judgment.

### Layer 3 fixes (coverage gaps)
```

- [ ] **Step 4: Insert new "Step 7: Manual Review block" section**

Find the line `## Output format` (around line 134). Insert the new section BEFORE it.

Edit the file: change `## Output format` to:

```markdown
## Step 7: Manual Review block

Some issues cannot be auto-fixed without domain knowledge. Surface each in
a structured list at the end of fix-spec output. For each item include:

- **Feature** — exact bullet text and any relevant attribute (touches_files, plugin)
- **Question** — the specific decision the user must make
- **Candidate fixes** — 2–3 concrete options (a, b, c) the user can adopt by editing the spec

Issue types that go here:

| Issue | Why manual |
|---|---|
| Gap 3: core/plugin `touches_files` overlap | Requires deciding which feature owns the conflicting file |
| Gap 5: `plugin="X"` references nonexistent directory | Could be typo, could be missing scaffold, could need creation |
| Gap 7: long core-on-core dependency chain | Requires architectural decomposition |
| Step 0: `shape="core"` missing `touches_files` | File list is domain knowledge |
| Step 0: `shape` typo with Levenshtein distance > 2 | Cannot guess intent |

**Example block (appended to output):**

```
Manual review needed (3 items):

  1. [billing] core feature touches_files overlap with src/plugins/billing/
     Feature: "All endpoints validate JWT" (touches_files: src/**)
     Question: which feature owns billing/auth.py — core or the billing plugin?
     Candidate fixes:
       a) Narrow core to src/core/**, src/middleware/**
       b) Move the plugin's auth.py to src/core/billing-auth.py
       c) Drop the overlap by excluding the plugin glob

  2. [profile] plugin="profile" references nonexistent directory
     Question: typo, or does the plugin not exist yet?
     Candidate fixes:
       a) Rename to plugin="profiles" (closest existing dir, distance 1)
       b) Override with explicit touches_files=
       c) Create src/plugins/profile/ via boundaries apply

  3. [auth-chain] depends_on chain of 5 core features
     Chain: auth-base → jwt-mw → rate-limit → audit-log → metrics
     Question: can any link be reshaped as a plugin instead of core?
     Suggestion: factor jwt-mw into a shape="plugin" plugin="jwt"
     by isolating its files to src/plugins/jwt/.
```

The user reads, picks (a/b/c) by editing the spec, then re-runs `/fix-spec`.

## Output format
```

- [ ] **Step 5: Verify the file structure**

Run: `grep -n "^## Step\|^### Layer" /Users/bowenli/development/claw-forge/.claude/commands/fix-spec.md`

Expected output (in order):
- `## Step 0: Handle parse-blocking errors`
- `## Step 1: Find the spec file`
- `## Step 2: Run validate-spec`
- `## Step 3: Parse the issues`
- `## Step 4: Fix the issues`
- `### Layer 1 fixes`
- `### Layer 2 fixes (LLM eval — low score on a dimension)`
- `### Layer 4 fixes (architectural shape)`
- `### Layer 3 fixes (coverage gaps)`
- `## Step 5: Write the fixed spec`
- `## Step 6: Re-run validate-spec`
- `## Step 7: Manual Review block`

- [ ] **Step 6: Commit**

```bash
git add .claude/commands/fix-spec.md
git commit -m "$(cat <<'EOF'
feat(commands): /fix-spec handles Layer 4 + parse errors + manual review

Three additions:
- Step 0 pre-pass for parser-level errors (typo Levenshtein-fix,
  manual-review for shape='core' missing touches_files)
- Layer 4 fix table with deterministic recipes for Gap 1 and Gap 6
- Step 7 Manual Review block for gaps that require domain judgment
  (3, 5, 7 and the parser-level cases that can't be auto-fixed)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 14: Documentation updates

**Files:**
- Modify: `CLAUDE.md` (add Layer 4 paragraph under spec/parser.py section)
- Modify: `docs/commands.md` (add `--strict-shape`, `--project-root`, Layer 4 entries)
- Modify: `README.md` (one sentence in spec-format section)

- [ ] **Step 1: Locate insertion points**

Run these in parallel:

```bash
grep -n "### spec/parser.py" /Users/bowenli/development/claw-forge/CLAUDE.md
grep -n "validate-spec" /Users/bowenli/development/claw-forge/docs/commands.md | head -10
grep -n "shape\|<feature " /Users/bowenli/development/claw-forge/README.md | head -10
```

Note the relevant line numbers.

- [ ] **Step 2: Add Layer 4 paragraph to CLAUDE.md**

Find the section under `### spec/parser.py` that ends with the example XML showing `shape="plugin"` and `shape="core"` (around the bullet "`shape="core"` without an explicit `touches_files` attribute raises a parse error..."). After the line `Specs without `shape` attributes parse identically to before — pure backward compatibility.`, insert a new subsection:

```markdown

### spec/validator.py — Layer 4 (Shape)

`claw-forge validate-spec` runs a fourth layer that catches every
shape-awareness gap left after the parser:

- **Gap 1** (ERROR): `shape="plugin"` features missing both `plugin=` and
  explicit `touches_files=` — silent acceptance would disable file-claim
  locking and break parallel-safety.
- **Gap 3** (WARNING / ERROR strict): a `shape="core"` feature's
  `touches_files` glob overlaps a plugin's directory. Conservative
  fnmatch probe — false-negatives over false-positives.
- **Gap 5** (WARNING / ERROR strict): brownfield-only — `plugin="X"`
  references a directory under `src/plugins/` that doesn't exist. Skipped
  on greenfield projects.
- **Gap 6** (WARNING / ERROR strict): heuristic — a category's unannotated
  bullets look vertical (`User can …`, `displays`, `creates`) but no shape
  is declared. Score uses `VERTICAL_INDICATORS` and `CROSS_CUTTING_INDICATORS`
  vocabularies; threshold is 0.6 vertical-ratio with ≥3 unannotated bullets.
- **Gap 7** (WARNING, never promoted): `depends_on` chain of `shape="core"`
  features depth ≥ 4 — every link serializes at dispatch time, so long
  core chains are an architectural smell worth surfacing.

The parser raises `ValueError` for two parse-time invariants that bypass
Layer 4 entirely (because the dispatcher reads `Task.shape` from the DB
and acts on it — bad values must not reach `claw-forge plan`):

- Unknown `shape=` values (e.g. `shape="ploogin"`).
- `shape="core"` without `touches_files=`.

`--strict-shape` promotes Gap 3, 5, 6 WARNINGs to ERRORs. Gap 1 is always
ERROR; Gap 7 is always WARNING. The flag is the migration on-ramp for
projects that have shape-annotated their spec.

`--project-root` overrides the auto-detected project root for filesystem
checks. Without it, validate-spec walks up from the spec file looking
for `.claw-forge.yaml` first, then `.git`. If neither is found, Layer 4
filesystem checks are skipped with a dim notice.
```

- [ ] **Step 3: Update `docs/commands.md`**

Find the `validate-spec` section. Add a "Layer 4 — Architectural shape" subsection alongside the existing L1/L2/L3 explanations, and add `--strict-shape` and `--project-root` to the flag listing for that command.

The exact insertion point is the existing description block for `claw-forge validate-spec`. Add to the flags list:

```markdown
- `--strict-shape` — promote Layer 4 shape WARNINGs (gaps 3, 5, 6) to ERRORs.
  Gap 1 is always ERROR; Gap 7 is always WARNING (not promoted). Use this
  once a project has migrated all of its features to shape annotations.
- `--project-root <path>` — override the auto-detected project root used for
  Layer 4 filesystem checks (verifying that `plugin="X"` references an
  existing directory). Without this flag, validate-spec walks up from the
  spec file looking for `.claw-forge.yaml` first, then `.git`.
```

And after the L3 block, add:

```markdown
**Layer 4 — Architectural shape** (catches gaps in `<feature shape>` annotations):

- Gap 1 (ERROR): `shape="plugin"` features missing both `plugin=` and `touches_files=`.
- Gap 3 (WARN / ERR strict): `shape="core"` `touches_files` overlapping a plugin directory.
- Gap 5 (WARN / ERR strict): brownfield-only — `plugin="X"` referencing nonexistent directory.
- Gap 6 (WARN / ERR strict): heuristic — vertical-looking category with no shape declared.
- Gap 7 (WARN, never promoted): long core-on-core `depends_on` chain (depth ≥ 4).

Parser-level guards (raise `ValueError` before validate-spec produces structured layers):
- Unknown `shape=` values (e.g. `shape="ploogin"`).
- `shape="core"` without `touches_files=`.
```

- [ ] **Step 4: Update README.md**

Find the spec-format section (likely under "Quick Start" or "Spec Format"). Add a single sentence near the existing `<feature>` example:

```markdown
Use `<feature shape="plugin" plugin="auth">` to opt into shape-aware scheduling
(plugin features dispatch in parallel; core features serialize). Run
`claw-forge validate-spec --strict-shape` to enforce shape annotations on every feature.
```

If no `<feature>` example exists yet, place this sentence in the closest equivalent section (e.g. "Defining your spec").

- [ ] **Step 5: Verify all three docs render**

Run: `head -100 /Users/bowenli/development/claw-forge/CLAUDE.md | grep -A 2 "Layer 4"`

Expected: Layer 4 section header is present.

- [ ] **Step 6: Commit**

```bash
git add CLAUDE.md docs/commands.md README.md
git commit -m "$(cat <<'EOF'
docs: shape-aware validate-spec Layer 4 + flags

- CLAUDE.md gains a spec/validator.py Layer 4 section explaining the
  four gaps and the strictness model
- docs/commands.md gains --strict-shape / --project-root flags and a
  Layer 4 explanation alongside the existing L1-L3 sections
- README.md gains one sentence pointing at shape annotations

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 15: Final verification — lint, type check, coverage, full suite

**Files:** None modified — verification only.

- [ ] **Step 1: Run linter**

Run: `uv run ruff check claw_forge/ tests/`

Expected: 0 issues. If issues found, fix them with:

Run: `uv run ruff check claw_forge/ tests/ --fix`

Then commit any fixes:

```bash
git add -p
git commit -m "style: ruff auto-fixes for shape-aware validation"
```

- [ ] **Step 2: Run type check**

Run: `uv run mypy claw_forge/ --ignore-missing-imports`

Expected: 0 errors. If errors are surfaced in newly-added code, fix them. Type the function signatures explicitly if mypy complains; do NOT silence errors with `# type: ignore` unless the error is in third-party code.

- [ ] **Step 3: Run full test suite with coverage**

Run: `uv run pytest tests/ -q --cov=claw_forge --cov-report=term-missing`

Expected:
- All tests pass.
- Coverage ≥ 90% (CI gate).
- New code in `claw_forge/spec/validator.py` shows ≥ 95% coverage. Any uncovered lines should be checked — if a branch is genuinely unreachable, document why; otherwise add a test.

- [ ] **Step 4: Run the e2e suite specifically**

Run: `uv run pytest tests/e2e/ -q`

Expected: All e2e tests pass, including the new `TestValidateSpecShape` class.

- [ ] **Step 5: Verify the parse-error path manually**

Create a temporary file with a typo'd shape and confirm validate-spec reports it:

```bash
cat > /tmp/bad_shape_spec.xml <<'EOF'
<?xml version="1.0"?>
<project>
  <project_name>x</project_name>
  <overview>x</overview>
  <tech_stack></tech_stack>
  <features>
    <category name="Auth">
      <feature index="1" shape="ploogin"><description>User can register</description></feature>
    </category>
  </features>
</project>
EOF
uv run claw-forge validate-spec /tmp/bad_shape_spec.xml --no-llm
echo "Exit code: $?"
rm /tmp/bad_shape_spec.xml
```

Expected output: `Failed to parse spec: <feature shape='ploogin'> '...' has unrecognized shape value...` and exit code 1.

- [ ] **Step 6: Final summary commit (only if previous steps revealed needed fixes)**

If steps 1–5 surfaced no issues, no commit is needed — the work is done. If fixes were applied during Step 1 (ruff) or Step 2 (mypy), they are already committed.

End-of-implementation checklist before declaring done:

- [ ] Parser raises on unknown shape values.
- [ ] All five Layer 4 check functions exist and are tested.
- [ ] `validate_spec()` runs Layer 4.
- [ ] CLI exposes `--strict-shape` and `--project-root`.
- [ ] CLI prints "Layer 4 (Shape)" output block with PASS / WARN / FAIL.
- [ ] CLI header summary shows shape-annotation count.
- [ ] E2E tests pass with three real fixture specs.
- [ ] `/fix-spec.md` has Step 0, Layer 4 fix table, and Step 7 Manual Review block.
- [ ] Docs updated in CLAUDE.md, docs/commands.md, README.md.
- [ ] Coverage ≥ 90% overall, ≥ 95% on new code.
- [ ] Linter and type checker clean.

---

## Self-Review Notes (for the implementing agent)

This plan was self-reviewed against the spec at
`docs/superpowers/specs/2026-05-07-shape-aware-spec-validation-design.md`.
Coverage check confirmed every locked decision (strictness model, layer
organization, severity matrix, /fix-spec policy) maps to a numbered task.
No placeholders, no "TBD", no "similar to Task N" hand-waving — every
step has explicit code or commands.

Two implementation notes:

1. **Layer ordering in CLI output:** The plan keeps Layer 4 visually after
   Layer 3 in the CLI output block. The validator's runtime order (L1, L2,
   L3, L4) matches.

2. **Parser-level errors short-circuit Layer 4.** When the parser raises
   (typo or core/touches_files missing), `validate_spec_cmd` catches the
   exception in the existing try/except at line ~2598 and prints "Failed
   to parse spec: …" before any layer runs. `/fix-spec`'s new Step 0 is
   the only place that handles those cases — by design.
