# Strict-by-Default Spec Validation + Gap 10 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:** Close the validate-spec loophole that let the AlphaStrike spec (declaring `plugin="agent"` with nonexistent `src/plugins/agent/**` globs) ship to production. Add a new Gap 10 check that fails when `touches_files` globs resolve to zero real files; fix the brownfield detector that was circular (it used the very directory it was validating); flip `--strict-shape` to default-on with `--soft-shape` opt-out; keep heuristic Gaps 6 and 7 as warnings.

**Architecture:** One new structural ERROR-by-default check (Gap 10) added to `run_shape_checks`. One new brownfield-detection helper `detect_brownfield(project_root)` that checks for project-existence markers (`.git/`, `pyproject.toml`, `package.json`, etc.) instead of the layout-specific `src/plugins/` directory. `validate_spec(..., strict_shape=True)` becomes the default; the CLI flag flips to opt-out (`--soft-shape`) with `--strict-shape` retained as a deprecated no-op alias for one release. Heuristic checks (Gap 6 vertical-detection, Gap 7 deep-core-chain) remain WARNING because they false-positive on legitimate cross-cutting code.

**Tech Stack:** Python 3.12, Typer (CLI), pytest + CliRunner (tests), Path/fnmatch + glob (existing globbing in `touches_files`).

**Root cause being fixed:** See `docs/superpowers/specs/2026-05-29-strict-by-default-shape-validation-design.md` — investigation in AlphaStrike found three "agent"-plugin tasks ran in parallel and collided on `src/agent/*.py` files because `touches_files=["src/plugins/agent/**"]` matched zero real files (AlphaStrike's agent code lives at `src/agent/`, not `src/plugins/agent/`). File-claim locks never engaged. The existing brownfield detector skipped Gap 5 entirely because `(project_root / "src/plugins").is_dir()` returned False — circular check.

---

## File Structure

**Files modified:**
- `claw_forge/spec/validator.py` — add `detect_brownfield`, `check_touches_files_resolve`; wire both into `run_shape_checks`; flip `validate_spec` default to `strict_shape=True` (~80 new lines, ~10 modified)
- `claw_forge/spec/cli.py` — flip `validate-spec` CLI flag default; add `--soft-shape` opt-out; keep `--strict-shape` as deprecated alias (~25 lines modified around line 341)
- `tests/spec/test_validator_shape.py` — add `pyproject.toml` setup to existing brownfield tests (5 tests touched); add new `TestDetectBrownfield` class; add new `TestCheckTouchesFilesResolve` class
- `tests/spec/test_validator.py` — review `validate_spec` integration tests for default-flip impact (~5 tests touched)
- `tests/spec/test_spec_cli_app.py` — add CLI flag tests for `--soft-shape` and `--strict-shape` deprecation
- `README.md` — one paragraph in the validate-spec section
- `docs/commands.md` — validate-spec section: list current gaps + default severities
- `CLAUDE.md` — `spec/validator.py` paragraph: add Gap 10 + brownfield-detector fix + default-flip note

**Files created:**
- `docs/superpowers/specs/2026-05-29-strict-by-default-shape-validation-design.md` — design doc capturing the AlphaStrike forensics and the four-part fix
- `tests/e2e/fixtures/specs_with_shape/touches_files_vacuous.xml` — regression fixture mimicking AlphaStrike's bug

---

## Task 1: Brownfield-detection helper

**Files:**
- Modify: `claw_forge/spec/validator.py` (add helper near top of Layer 4 section, around line 631)
- Test: `tests/spec/test_validator_shape.py` (new `TestDetectBrownfield` class)

- [ ] **Step 1: Write failing tests for `detect_brownfield`**

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

```python
class TestDetectBrownfield:
    """detect_brownfield(project_root) — True if project_root contains
    pre-existing code per layout-agnostic markers (.git, pyproject.toml,
    package.json, etc.). Replaces the circular src/plugins/-based check."""

    def test_empty_dir_is_greenfield(self, tmp_path: Path) -> None:
        from claw_forge.spec.validator import detect_brownfield
        assert detect_brownfield(tmp_path) is False

    def test_git_dir_is_brownfield(self, tmp_path: Path) -> None:
        from claw_forge.spec.validator import detect_brownfield
        (tmp_path / ".git").mkdir()
        assert detect_brownfield(tmp_path) is True

    def test_pyproject_is_brownfield(self, tmp_path: Path) -> None:
        from claw_forge.spec.validator import detect_brownfield
        (tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n")
        assert detect_brownfield(tmp_path) is True

    def test_package_json_is_brownfield(self, tmp_path: Path) -> None:
        from claw_forge.spec.validator import detect_brownfield
        (tmp_path / "package.json").write_text("{}")
        assert detect_brownfield(tmp_path) is True

    def test_go_mod_is_brownfield(self, tmp_path: Path) -> None:
        from claw_forge.spec.validator import detect_brownfield
        (tmp_path / "go.mod").write_text("module x\n")
        assert detect_brownfield(tmp_path) is True

    def test_cargo_toml_is_brownfield(self, tmp_path: Path) -> None:
        from claw_forge.spec.validator import detect_brownfield
        (tmp_path / "Cargo.toml").write_text("[package]\nname='x'\n")
        assert detect_brownfield(tmp_path) is True

    def test_legacy_src_plugins_still_works(self, tmp_path: Path) -> None:
        """Back-compat: projects that DO follow the src/plugins/ convention
        still detect as brownfield even with no other marker."""
        from claw_forge.spec.validator import detect_brownfield
        (tmp_path / "src" / "plugins" / "auth").mkdir(parents=True)
        assert detect_brownfield(tmp_path) is True

    def test_alphastrike_style_layout(self, tmp_path: Path) -> None:
        """Regression: a project with src/<dir>/ layout but no src/plugins/
        is brownfield (the AlphaStrike bug class)."""
        from claw_forge.spec.validator import detect_brownfield
        (tmp_path / "src" / "agent").mkdir(parents=True)
        (tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n")
        assert detect_brownfield(tmp_path) is True
```

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

Run: `uv run pytest tests/spec/test_validator_shape.py::TestDetectBrownfield -v`
Expected: 8 FAIL with `ImportError: cannot import name 'detect_brownfield'`

- [ ] **Step 3: Implement `detect_brownfield`**

Add to `claw_forge/spec/validator.py` after the `DEFAULT_MIGRATION_TOUCHES_FILES` constant (around line 678) and before `_slugify_plugin_name`:

```python
# Layout-agnostic markers that indicate a project root contains
# pre-existing code.  Used by detect_brownfield() to gate the
# filesystem-aware Layer 4 checks (Gap 5 plugin-dir, Gap 10
# vacuous-touches-files).  Order matters only for the early-exit
# loop; presence of any one marker is sufficient.
_BROWNFIELD_MARKERS: tuple[str, ...] = (
    ".git",
    "pyproject.toml",
    "package.json",
    "go.mod",
    "Cargo.toml",
    "Gemfile",
    "build.gradle",
    "pom.xml",
    "composer.json",
    "src/plugins",  # legacy: claw-forge-conventional layout
)


def detect_brownfield(project_root: Path) -> bool:
    """Return True if *project_root* contains pre-existing code.

    Detection is layout-agnostic: checks for the presence of any
    standard project-existence marker (.git, language-specific
    manifests, or the legacy claw-forge ``src/plugins/`` convention).
    Replaces the v0.8.x detector that gated on
    ``(project_root / "src/plugins").is_dir()`` — circular, because
    that's the very directory Gap 5 was supposed to validate.

    Used by Gap 5 (``check_plugin_dir_exists``) and Gap 10
    (``check_touches_files_resolve``).  Greenfield projects with no
    code yet skip both checks; aspirational touches_files
    declarations are trusted until a brownfield marker appears.
    """
    return any((project_root / marker).exists() for marker in _BROWNFIELD_MARKERS)
```

Also update the module `__all__` at top of file to include `detect_brownfield`.

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

Run: `uv run pytest tests/spec/test_validator_shape.py::TestDetectBrownfield -v`
Expected: 8 PASS

- [ ] **Step 5: Commit**

```bash
git add claw_forge/spec/validator.py tests/spec/test_validator_shape.py
git commit -m "feat(validator): layout-agnostic brownfield detection

Replace circular brownfield check ((project_root / src/plugins).is_dir())
with detect_brownfield() that probes layout-agnostic markers (.git,
pyproject.toml, package.json, go.mod, Cargo.toml, etc.). The old check
short-circuited Gap 5 for any project that didn't already follow the
src/plugins/ convention — the AlphaStrike bug class. Legacy src/plugins/
detection retained for back-compat."
```

---

## Task 2: Wire `detect_brownfield` into `run_shape_checks`

**Files:**
- Modify: `claw_forge/spec/validator.py:1105-1115` (the `# Filesystem (brownfield only)` block in `run_shape_checks`)
- Test: `tests/spec/test_validator_shape.py` (update 5 existing brownfield tests)

- [ ] **Step 1: Update `run_shape_checks` to use `detect_brownfield`**

Replace lines 1105-1108 in `claw_forge/spec/validator.py`:

```python
    # Filesystem (brownfield only)
    if project_root is not None:
        plugin_root_dir = project_root / DEFAULT_PLUGIN_ROOT
        brownfield = plugin_root_dir.is_dir()
```

with:

```python
    # Filesystem (brownfield only) — detect via layout-agnostic markers,
    # not the src/plugins/ directory whose existence Gap 5 is meant
    # to validate.
    if project_root is not None:
        brownfield = detect_brownfield(project_root)
```

The `from claw_forge.spec.parser import DEFAULT_PLUGIN_ROOT` import at line 1070 stays — Gap 3 still uses it.

- [ ] **Step 2: Update existing brownfield tests to add pyproject.toml setup**

Five tests in `tests/spec/test_validator_shape.py` rely on `(tmp_path / "src" / "plugins" / ...).mkdir(...)` to trigger brownfield mode in `run_shape_checks` integration tests. Find them with:

```bash
grep -n "src.*plugins.*mkdir\|brownfield" tests/spec/test_validator_shape.py
```

For each test whose intent is "this is a brownfield project," explicitly add `(tmp_path / "pyproject.toml").write_text("")` before the existing setup. The tests in question are at lines around 679, 689, 703, 717, 730, 801, 823 (verify by re-reading). They previously passed because `src/plugins/auth/` doubled as the brownfield trigger; now they need an explicit marker.

Example pattern — change:

```python
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
```

This particular test passes `brownfield=True` explicitly, so it does NOT need updating — it's a unit test of `check_plugin_dir_exists`, not of the detection logic. The tests needing updates are `TestRunShapeChecks` integration tests that rely on auto-detection. Audit by running the suite (next step) and updating only the ones that fail.

- [ ] **Step 3: Run full Layer 4 test suite**

Run: `uv run pytest tests/spec/test_validator_shape.py -v`
Expected: only the integration tests that rely on auto-detection (no explicit `pyproject.toml` setup) may need updating. Update each failure by adding `(tmp_path / "pyproject.toml").write_text("")` to its setup.

- [ ] **Step 4: Run full validator test suite**

Run: `uv run pytest tests/spec/ -v`
Expected: all green.

- [ ] **Step 5: Commit**

```bash
git add claw_forge/spec/validator.py tests/spec/test_validator_shape.py
git commit -m "fix(validator): use detect_brownfield in run_shape_checks

Filesystem-aware Layer 4 checks (currently Gap 5) now gate on
detect_brownfield() instead of (project_root / src/plugins).is_dir().
Catches the AlphaStrike bug class: projects with non-conventional
src/ layouts (src/<dir>/ instead of src/plugins/<dir>/) now correctly
detect as brownfield and run Gap 5."
```

---

## Task 3: Gap 10 — `touches_files` globs must resolve to ≥1 real file

**Files:**
- Modify: `claw_forge/spec/validator.py` (add new check function ~70 lines; wire into `run_shape_checks`)
- Test: `tests/spec/test_validator_shape.py` (new `TestCheckTouchesFilesResolve` class)

- [ ] **Step 1: Write failing tests for `check_touches_files_resolve`**

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

```python
class TestCheckTouchesFilesResolve:
    """Gap 10 — every touches_files glob must match at least one real
    file under project_root.  Catches the AlphaStrike bug class where
    plugin='X' resolves to a default glob 'src/plugins/X/**' that
    matches zero files because the codebase uses 'src/X/' instead."""

    def _feat(
        self,
        touches: list[str],
        *,
        shape: str = "plugin",
        plugin: str | None = "auth",
    ) -> FeatureItem:
        return FeatureItem(
            category="Auth", name="register",
            description="User can register with email and password",
            shape=shape, plugin=plugin, touches_files=touches,
        )

    def test_glob_matches_files_passes(self, tmp_path: Path) -> None:
        from claw_forge.spec.validator import check_touches_files_resolve
        (tmp_path / "src" / "auth").mkdir(parents=True)
        (tmp_path / "src" / "auth" / "register.py").write_text("")
        feat = self._feat(["src/auth/**"])
        assert check_touches_files_resolve(
            feat, project_root=tmp_path, brownfield=True,
        ) is None

    def test_vacuous_glob_errors(self, tmp_path: Path) -> None:
        """The AlphaStrike bug: plugin='auth' with default
        src/plugins/auth/** matches nothing when codebase has src/auth/."""
        from claw_forge.spec.validator import check_touches_files_resolve
        (tmp_path / "src" / "auth").mkdir(parents=True)
        (tmp_path / "src" / "auth" / "register.py").write_text("")
        feat = self._feat(["src/plugins/auth/**"])
        issue = check_touches_files_resolve(
            feat, project_root=tmp_path, brownfield=True,
        )
        assert issue is not None
        assert issue.severity == IssueSeverity.ERROR
        assert issue.layer == 4
        assert "src/plugins/auth/**" in issue.message
        assert "matches no files" in issue.message.lower() or \
               "resolves to zero" in issue.message.lower()

    def test_one_good_one_bad_still_errors(self, tmp_path: Path) -> None:
        """If ANY glob in touches_files matches zero files, error.
        Partial-match is not enough — the bad glob is still silently
        disabling locking for one path."""
        from claw_forge.spec.validator import check_touches_files_resolve
        (tmp_path / "src" / "auth").mkdir(parents=True)
        (tmp_path / "src" / "auth" / "register.py").write_text("")
        feat = self._feat(["src/auth/**", "src/plugins/auth/**"])
        issue = check_touches_files_resolve(
            feat, project_root=tmp_path, brownfield=True,
        )
        assert issue is not None
        assert "src/plugins/auth/**" in issue.message

    def test_literal_path_matches_existing_file_passes(
        self, tmp_path: Path,
    ) -> None:
        """Non-glob literal paths (shape='core' usage) — file must exist."""
        from claw_forge.spec.validator import check_touches_files_resolve
        (tmp_path / "src" / "core").mkdir(parents=True)
        (tmp_path / "src" / "core" / "risk.py").write_text("")
        feat = self._feat(
            ["src/core/risk.py"], shape="core", plugin=None,
        )
        assert check_touches_files_resolve(
            feat, project_root=tmp_path, brownfield=True,
        ) is None

    def test_literal_path_missing_errors(self, tmp_path: Path) -> None:
        from claw_forge.spec.validator import check_touches_files_resolve
        (tmp_path / "src" / "core").mkdir(parents=True)
        feat = self._feat(
            ["src/core/nonexistent.py"], shape="core", plugin=None,
        )
        issue = check_touches_files_resolve(
            feat, project_root=tmp_path, brownfield=True,
        )
        assert issue is not None
        assert issue.severity == IssueSeverity.ERROR

    def test_greenfield_skips(self, tmp_path: Path) -> None:
        """Greenfield projects have no code yet — touches_files are
        aspirational.  Gap 10 must not fire."""
        from claw_forge.spec.validator import check_touches_files_resolve
        feat = self._feat(["src/plugins/auth/**"])
        assert check_touches_files_resolve(
            feat, project_root=tmp_path, brownfield=False,
        ) is None

    def test_empty_touches_files_skips(self, tmp_path: Path) -> None:
        """No touches_files = no claim. Gap 1 handles missing-claim
        validation; Gap 10 is only meaningful when claims exist."""
        from claw_forge.spec.validator import check_touches_files_resolve
        (tmp_path / "pyproject.toml").write_text("")
        feat = self._feat([])
        assert check_touches_files_resolve(
            feat, project_root=tmp_path, brownfield=True,
        ) is None

    def test_message_lists_offending_globs(self, tmp_path: Path) -> None:
        """Suggestion should be actionable — name the bad glob and
        propose checking the layout."""
        from claw_forge.spec.validator import check_touches_files_resolve
        (tmp_path / "src" / "auth").mkdir(parents=True)
        (tmp_path / "src" / "auth" / "x.py").write_text("")
        feat = self._feat(["src/wrong_path/**"])
        issue = check_touches_files_resolve(
            feat, project_root=tmp_path, brownfield=True,
        )
        assert issue is not None
        assert "src/wrong_path/**" in issue.message
        assert issue.suggestion
```

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

Run: `uv run pytest tests/spec/test_validator_shape.py::TestCheckTouchesFilesResolve -v`
Expected: 8 FAIL with `ImportError: cannot import name 'check_touches_files_resolve'`

- [ ] **Step 3: Implement `check_touches_files_resolve`**

Add to `claw_forge/spec/validator.py` after `check_plugin_dir_exists` (around line 996):

```python
def check_touches_files_resolve(
    feat: FeatureItem,
    project_root: Path,
    *,
    brownfield: bool,
) -> ValidationIssue | None:
    """Gap 10 — every glob in ``touches_files`` must match ≥1 real file.

    The AlphaStrike bug class: ``plugin="agent"`` materializes to the
    default ``touches_files=["src/plugins/agent/**"]``.  When the
    codebase puts agent code at ``src/agent/`` instead, the glob
    matches zero files.  The dispatcher's file-claim system sees an
    empty claim list and silently dispatches the task with no lock —
    parallel agents collide on the real ``src/agent/*.py`` files.

    Always ERROR (no strict/soft toggle).  A vacuous claim has the
    same severity as a missing claim — both silently disable parallel
    safety.  Brownfield-only: greenfield projects have no code yet,
    so aspirational touches_files declarations are trusted.

    Matching algorithm: each glob is expanded via ``Path.glob()`` from
    ``project_root``; literal paths are checked via ``.exists()``.
    A glob "matches" if it yields ≥1 path; a literal "matches" if the
    path exists.  Mixed lists (some globs + some literals) are all
    required to match — partial coverage means partial locking, which
    is the same failure mode as no locking.
    """
    if not brownfield:
        return None
    if not feat.touches_files:
        return None

    unresolved: list[str] = []
    for entry in feat.touches_files:
        entry_stripped = entry.strip()
        if not entry_stripped:
            continue
        has_glob = any(ch in entry_stripped for ch in "*?[")
        if has_glob:
            # Path.glob() on a single segment with "**" requires explicit
            # treatment — we expand from project_root and check non-empty.
            matched = next(iter(project_root.glob(entry_stripped)), None)
            if matched is None:
                unresolved.append(entry_stripped)
        else:
            if not (project_root / entry_stripped).exists():
                unresolved.append(entry_stripped)

    if not unresolved:
        return None

    unresolved_str = ", ".join(unresolved)
    return ValidationIssue(
        severity=IssueSeverity.ERROR,
        layer=4,
        category=feat.category,
        bullet=feat.description,
        message=(
            f"touches_files entries matches no files in the project: "
            f"{unresolved_str}.  File-claim locking is silently disabled "
            f"for these paths — parallel agents will collide on whatever "
            f"files they actually touch."
        ),
        suggestion=(
            "Update touches_files to globs that match real paths in this "
            "codebase, or restructure the codebase to match the declared "
            "layout.  Common cause: copying a spec template authored "
            "against src/plugins/<name>/ into a project that uses a "
            "different src/ layout."
        ),
    )
```

Also add `"check_touches_files_resolve"` to the module `__all__` list.

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

Run: `uv run pytest tests/spec/test_validator_shape.py::TestCheckTouchesFilesResolve -v`
Expected: 8 PASS

- [ ] **Step 5: Wire `check_touches_files_resolve` into `run_shape_checks`**

In `claw_forge/spec/validator.py:run_shape_checks`, find the `# Filesystem (brownfield only)` block (now updated in Task 2 to use `detect_brownfield`) and add the Gap 10 call alongside the existing Gap 5 call:

```python
    # Filesystem (brownfield only) — detect via layout-agnostic markers
    if project_root is not None:
        brownfield = detect_brownfield(project_root)
        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)
            issue = check_touches_files_resolve(
                feat, project_root,
                brownfield=brownfield,
            )
            if issue is not None:
                issues.append(issue)
```

Update the docstring at the top of `run_shape_checks` to list Gap 10 alongside Gaps 5 and 7.

- [ ] **Step 6: Run full Layer 4 test suite**

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

- [ ] **Step 7: Commit**

```bash
git add claw_forge/spec/validator.py tests/spec/test_validator_shape.py
git commit -m "feat(validator): Gap 10 — touches_files globs must resolve

New check_touches_files_resolve() catches the AlphaStrike bug class:
plugin='agent' default-resolves to src/plugins/agent/**, which matches
zero files in projects with non-conventional src/ layouts. The
dispatcher's file-claim system then silently dispatches with empty
claim lists; parallel agents collide on real shared files.

Always ERROR. Brownfield-only — greenfield touches_files are
aspirational. Wired into run_shape_checks alongside Gap 5."
```

---

## Task 4: Flip `strict_shape` default to True; deprecate `--strict-shape`

**Files:**
- Modify: `claw_forge/spec/validator.py:1126` (flip `validate_spec` default)
- Modify: `claw_forge/spec/cli.py:341-352` (CLI flag changes)
- Test: `tests/spec/test_spec_cli_app.py` (CLI flag tests)
- Test: `tests/spec/test_validator.py` (review default-flip impact)

- [ ] **Step 1: Audit existing tests for `strict_shape=False` expectations**

Run:

```bash
grep -rn "strict_shape\s*=\s*False\|strict_shape=False" tests/ | head -20
grep -rn "validate_spec(" tests/ | head -20
```

For each call to `validate_spec(...)` without an explicit `strict_shape=` argument, the test currently expects default `False`.  After the flip, those tests will see default `True`.  Categorize each call:

1. **Test is asserting WARNING behavior** → either add explicit `strict_shape=False` to preserve old behavior, OR update assertion to expect ERROR (whichever matches the test's intent).
2. **Test is asserting ERROR behavior** → no change needed; default flip is invisible.
3. **Test is asserting WARNING-without-strict semantics specifically** → use the new `--soft-shape` opt-out path; add explicit `strict_shape=False`.

List the file:line locations needing updates in this step's commit message.

- [ ] **Step 2: Flip the default in `validate_spec`**

In `claw_forge/spec/validator.py`, change the signature of `validate_spec` (line 1126):

```python
    strict_shape: bool = False,
```

to:

```python
    strict_shape: bool = True,
```

Update the docstring (lines 1143-1145) from:

```
    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).
```

to:

```
    strict_shape:
        Default True (v0.8.36+).  When True, promote Layer 4 WARNINGs
        for Gaps 3, 5, 9 to ERROR.  Gaps 1, 8, 10 are always ERROR;
        Gaps 6 (vertical-heuristic) and 7 (deep core chain) are always
        WARNING — they're vocabulary-driven heuristics that
        false-positive on legitimate cross-cutting code.  Pass
        ``strict_shape=False`` (CLI: ``--soft-shape``) to keep all
        promotable warnings at WARNING severity; useful during the
        migration on-ramp when an older spec is being brought up to
        strict standards.
```

- [ ] **Step 3: Update CLI flag handling**

In `claw_forge/spec/cli.py`, replace the `strict_shape` Typer option (lines 341-344):

```python
    strict_shape: bool = typer.Option(
        False, "--strict-shape",
        help="Promote shape WARNINGs (gaps 3, 5, 6) to ERRORs.",
    ),
```

with:

```python
    soft_shape: bool = typer.Option(
        False, "--soft-shape",
        help=(
            "Opt out of strict shape validation (default since v0.8.36): "
            "keep Layer 4 WARNINGs at WARNING severity instead of "
            "promoting to ERROR.  Use during the migration on-ramp when "
            "an older spec hasn't been brought up to strict standards yet."
        ),
    ),
    strict_shape_legacy: bool = typer.Option(
        False, "--strict-shape", hidden=True,
        help="Deprecated no-op (strict-shape is now default).",
    ),
```

Then in the function body, replace the `strict_shape=strict_shape` argument in the `validate_spec(...)` call (around line 409) with `strict_shape=not soft_shape`.

After the call, add a deprecation warning if the legacy flag was used:

```python
    if strict_shape_legacy:
        console.print(
            "[dim]Note: --strict-shape is now the default; "
            "the flag is a deprecated no-op.[/dim]"
        )
```

Update the rerun-hint text (lines 491-492):

```python
    rerun_flags = " --strict-shape" if strict_shape else ""
```

becomes:

```python
    rerun_flags = " --soft-shape" if soft_shape else ""
```

- [ ] **Step 4: Add CLI flag tests**

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

```python
class TestSoftShapeFlag:
    """--soft-shape opts out of the v0.8.36+ strict-by-default behavior."""

    def test_default_is_strict(self, tmp_path: Path) -> None:
        from typer.testing import CliRunner
        from claw_forge.spec.cli import spec_app
        # ... build a fixture spec with a Gap-3-eligible warning ...
        # Verify it exits non-zero (warning promoted to error)
        # See existing test_strict_shape_kwarg_promotes pattern.

    def test_soft_shape_keeps_warnings(self, tmp_path: Path) -> None:
        # Same fixture, with --soft-shape → exit 0, warnings only
        ...

    def test_legacy_strict_shape_flag_is_no_op(self, tmp_path: Path) -> None:
        # Passing --strict-shape explicitly should behave identically to
        # the new default; ideally print a deprecation note.
        ...
```

Use the existing `test_strict_shape_kwarg_promotes` test in `tests/spec/test_validator.py:796` as a reference for the fixture-construction pattern. Fill in concrete CliRunner invocations matching the existing test style in that file.

- [ ] **Step 5: Update existing tests per Step 1 audit**

Apply the changes catalogued in Step 1 — each test gets either an explicit `strict_shape=False` to preserve old behavior, or an updated assertion.

- [ ] **Step 6: Run full validator + CLI test suites**

Run: `uv run pytest tests/spec/ -v`
Expected: all PASS.

- [ ] **Step 7: Commit**

```bash
git add claw_forge/spec/validator.py claw_forge/spec/cli.py tests/spec/
git commit -m "feat(validator): strict-shape on by default; add --soft-shape opt-out

Flip validate_spec(strict_shape=True) default and CLI --strict-shape →
--soft-shape opt-out. Legacy --strict-shape kept as deprecated no-op
alias for one release. Gaps 1, 8, 10 stay always-ERROR; Gaps 6 and 7
stay always-WARNING (vocabulary heuristics that false-positive on
legitimate cross-cutting code). Existing tests that depended on
opt-in strict semantics updated to pass strict_shape=False explicitly
or to assert the new ERROR behavior."
```

---

## Task 5: Documentation updates

**Files:**
- Modify: `README.md`
- Modify: `docs/commands.md`
- Modify: `CLAUDE.md`

- [ ] **Step 1: Update README**

Find the validate-spec mention in `README.md` (search for `validate-spec`). Add or extend the relevant paragraph:

```markdown
`claw-forge validate-spec` now runs strict shape validation by default.
The validator refuses specs whose `touches_files` globs match no real
files in the project, the most common cause of parallel-agent
collisions on plugin-shape tasks. Pass `--soft-shape` to opt out during
migration on-ramp.
```

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

Find the `validate-spec` section in `docs/commands.md`. Update the gap table (or add one if missing) to reflect current severities:

```markdown
| Gap | Check | Severity (default / --soft-shape) |
|-----|-------|------------------------------------|
| 1   | shape="plugin" missing both plugin= and touches_files= | ERROR / ERROR |
| 3   | core touches_files overlaps plugin dir | ERROR / WARNING |
| 5   | plugin= references nonexistent directory (brownfield) | ERROR / WARNING |
| 6   | category bullets look vertical but no shape declared | WARNING / WARNING (heuristic) |
| 7   | shape="core" depends_on chain ≥ 4 | WARNING / WARNING (architectural smell, may be intentional) |
| 8   | feature description looks migration-related but not shape="core" | ERROR / WARNING (via --allow-soft-migration-shape) |
| 9   | feature has no explicit depends_on and category words don't match any phase | ERROR / WARNING |
| 10  | touches_files glob matches no files in the project (brownfield) | ERROR / ERROR |
```

Add a "Brownfield detection" subsection explaining the new `detect_brownfield` markers.

- [ ] **Step 3: Update CLAUDE.md**

Find the `### spec/validator.py — Layer 4 (Shape)` paragraph. Add Gap 10 between Gap 9 and the parser-raise-time invariants paragraph:

```markdown
- **Gap 10** (ERROR, brownfield-only): a feature's resolved
  `touches_files` globs (after defaulting from `plugin=`) match zero
  real files under `project_root`.  The dispatcher's file-claim
  locking is silently disabled for the affected paths — parallel
  agents collide on whatever files they actually touch.  Common
  cause: a spec authored against `src/plugins/<name>/` layout was
  applied to a project that uses a different `src/` layout.
  Always ERROR (no severity toggle).  Brownfield-gated via the new
  `detect_brownfield` helper that checks for `.git`, `pyproject.toml`,
  `package.json`, `go.mod`, `Cargo.toml`, etc. — replacing the
  v0.8.x detector that was circular (it gated on the very
  `src/plugins/` directory Gap 5 was meant to validate).
```

Also update the line listing the `--strict-shape` flag — the flag is now `--soft-shape` (opt-out) with `--strict-shape` deprecated.

- [ ] **Step 4: Commit**

```bash
git add README.md docs/commands.md CLAUDE.md
git commit -m "docs: strict-shape default + Gap 10 + brownfield-detector fix"
```

---

## Task 6: End-to-end regression — AlphaStrike-style fixture

**Files:**
- Create: `tests/e2e/fixtures/specs_with_shape/touches_files_vacuous.xml`
- Modify: `tests/e2e/test_cli_e2e.py` (extend existing `TestValidateSpecShape`)

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

Write `tests/e2e/fixtures/specs_with_shape/touches_files_vacuous.xml`:

```xml
<?xml version="1.0"?>
<project_specification mode="brownfield">
  <project_name>regression-alphastrike-touches-vacuous</project_name>
  <overview>
    Regression fixture for the v0.8.36 Gap 10 introduction.  Mirrors
    the AlphaStrike bug: plugin="agent" defaults to
    touches_files=["src/plugins/agent/**"] but the brownfield project
    layout uses src/agent/ instead.  File-claim locking would be
    silently disabled.
  </overview>
  <technology_stack><backend><language>Python</language></backend></technology_stack>
  <prerequisites>none</prerequisites>
  <core_features>
    <category name="Agent">
      <feature index="1" shape="plugin" plugin="agent">
        <description>System agent decides APPROVE or BLOCK and emits a rationale</description>
      </feature>
    </category>
  </core_features>
  <implementation_steps>
    <phase>Phase 1 - Agent</phase>
  </implementation_steps>
</project_specification>
```

- [ ] **Step 2: Add an e2e test that exercises the fixture**

Append to `tests/e2e/test_cli_e2e.py` inside the existing `TestValidateSpecShape` class (or create one if missing):

```python
def test_alphastrike_regression_vacuous_touches_files(
    self, tmp_path: Path,
) -> None:
    """Regression: validate-spec must reject specs whose plugin=
    defaults to a glob that matches zero real files."""
    from typer.testing import CliRunner
    from claw_forge.spec.cli import spec_app

    # Brownfield layout: src/agent/ exists, src/plugins/ does NOT
    (tmp_path / "src" / "agent").mkdir(parents=True)
    (tmp_path / "src" / "agent" / "nodes.py").write_text("")
    (tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n")

    fixture = (
        Path(__file__).parent / "fixtures" / "specs_with_shape"
        / "touches_files_vacuous.xml"
    )
    target = tmp_path / "spec.xml"
    target.write_text(fixture.read_text())

    runner = CliRunner()
    result = runner.invoke(
        spec_app,
        [
            "validate-spec", str(target), "--no-llm",
            "--project-root", str(tmp_path),
        ],
    )
    assert result.exit_code != 0, (
        "validate-spec must fail when touches_files globs are vacuous"
    )
    assert "touches_files" in result.stdout
    assert "src/plugins/agent/**" in result.stdout

def test_alphastrike_regression_passes_under_soft_shape(
    self, tmp_path: Path,
) -> None:
    """--soft-shape opts out of Gap 10? No — Gap 10 is always ERROR
    even under --soft-shape.  This test pins that behavior."""
    # ... same setup as above ...
    result = runner.invoke(
        spec_app,
        [
            "validate-spec", str(target), "--no-llm", "--soft-shape",
            "--project-root", str(tmp_path),
        ],
    )
    assert result.exit_code != 0, (
        "Gap 10 is always ERROR; --soft-shape does not bypass it"
    )
```

- [ ] **Step 3: Run the e2e test**

Run: `uv run pytest tests/e2e/test_cli_e2e.py::TestValidateSpecShape -v`
Expected: 2 new tests PASS.

- [ ] **Step 4: Run the full test suite for confidence**

Run: `uv run pytest tests/ -q`
Expected: green.

Run: `uv run pytest tests/ -q --cov=claw_forge --cov-report=term-missing | tail -30`
Expected: coverage ≥ 90% (project gate).

- [ ] **Step 5: Lint everything**

Run: `uv run ruff check claw_forge/ tests/`
Expected: no errors.

Run: `uv run mypy claw_forge/ --ignore-missing-imports`
Expected: no errors.

- [ ] **Step 6: Commit**

```bash
git add tests/e2e/
git commit -m "test(e2e): AlphaStrike regression for Gap 10"
```

---

## Task 7: Validate against the real AlphaStrike spec (sanity check)

**Files:**
- None modified; this task is a manual verification.

- [ ] **Step 1: Run the freshly-hardened validator against AlphaStrike's spec**

Run from the claw-forge repo:

```bash
uv run claw-forge validate-spec \
    --no-llm \
    --project-root /Users/bowenli/development/AlphaStrike \
    /Users/bowenli/development/AlphaStrike/app_spec.txt
```

Expected: PASS (the Phase 1 spec fix made every `touches_files` resolve to real files).

- [ ] **Step 2: Re-confirm the bug regression**

Temporarily revert one of the AlphaStrike spec edits (e.g., remove `touches_files="src/agent/**"` from a `plugin="agent"` feature) and re-run. Expected: FAIL with the Gap 10 error message naming `src/plugins/agent/**`.

Restore the edit immediately after confirming.

- [ ] **Step 3: Update memory note (optional)**

Consider adding an entry to `memory/` describing the AlphaStrike root cause, the four-part fix, and the new severity matrix so future debugging recalls this context.

---

## Self-review checklist (run after writing all tasks)

**1. Spec coverage**:

- [x] Gap 10 added (Task 3)
- [x] Strict-by-default flip (Task 4)
- [x] Brownfield detection fix (Task 1 + Task 2)
- [x] Gaps 6 & 7 stay as warnings (preserved by virtue of *not* modifying them in any task)
- [x] Docs updates: README, docs/commands.md, CLAUDE.md (Task 5)
- [x] Regression test fixture for AlphaStrike scenario (Task 6)

**2. Placeholder scan**: each step contains either runnable code or a concrete command.  No TBD/TODO blocks. Step 1 of Task 4 is the closest to placeholder — it says "audit existing tests" with `grep` commands and a categorization rubric, which is concrete process guidance rather than a TODO.

**3. Type consistency**:
- `detect_brownfield(project_root: Path) -> bool` referenced in Tasks 1, 2, 3 — consistent
- `check_touches_files_resolve(feat, project_root, *, brownfield)` referenced in Tasks 3, 6 — consistent
- `_BROWNFIELD_MARKERS` tuple named consistently
- CLI flag rename `--strict-shape` → `--soft-shape` (opt-out) referenced consistently across Tasks 4, 5, 6
