# Cycle-Safe Dependency Inference 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:** Make `claw-forge plan` and `claw-forge run` cycle-safe by removing the middle-phase fallback in `_assign_dependencies`, adding a defense-in-depth cycle filter, surfacing affected features via a new Layer 4 Gap 9 validator check, and extending `/fix-spec` with an auto-fix rule. The downstream `Scheduler.validate_no_cycles()` should never see an inferred-edge cycle again.

**Architecture:** Three independent layers, each with its own tests. Layer 1: the parser (`claw_forge/spec/parser.py:_assign_dependencies`) gets two changes — delete the middle-phase fallback branch (root-cause fix) and add a cycle-safe filter that drops inferred edges that would close a cycle (defense in depth). Layer 2: the validator (`claw_forge/spec/validator.py`) gets a new per-feature check `check_unmatched_phase_inference` that surfaces features whose category falls through to the new no-inference path, emitting `Gap 9` at WARNING / ERROR-under-strict-shape severity. Layer 3: the `/fix-spec` slash command (`.claude/commands/fix-spec.md`) gets a new fix rule that adds `depends_on="<prev-index-in-same-category>"` for non-first features and routes first-in-category cases to Manual Review.

**Tech Stack:** Python 3.12, pytest, ruff, mypy. No new dependencies.

**Spec:** `docs/superpowers/specs/2026-05-16-cycle-safe-dependency-inference-design.md`

---

## File Structure

Files to modify:

| File | Responsibility | Change |
|---|---|---|
| `claw_forge/spec/parser.py` | Parses spec XML into `ProjectSpec`; runs `_assign_dependencies` | Delete middle-phase fallback (lines 642-644); add `_filter_inferred_edges_breaking_cycles` helper called at end of `_assign_dependencies` |
| `claw_forge/spec/validator.py` | Runs Layer 1-4 validation checks | Add `check_unmatched_phase_inference` (new Gap 9); wire into `run_shape_checks`; thread `spec.implementation_phases` through `validate_spec → run_shape_checks` |
| `tests/spec/test_parser.py` | Tests parser behavior | Rewrite one test (was `test_unmatched_features_go_to_middle_phase`); add 3 new tests for the cycle filter |
| `tests/spec/test_validator.py` | Tests validator behavior | Add 5 new tests for Gap 9 |
| `.claude/commands/fix-spec.md` | Slash command rules | Add Gap 9 row to Layer 4 fix table; add worked example; add first-in-category Manual Review block entry |
| `CLAUDE.md` | Project guidance | Update `spec/parser.py` section + Layer 4 gap list |

No new files. No deletions.

---

## Phase A — Parser fix (Tasks 1-6)

### Task 1: Rewrite the failing test for Change A

**Files:**
- Modify: `tests/spec/test_parser.py:453-462`

- [ ] **Step 1: Replace the existing `test_unmatched_features_go_to_middle_phase` test**

The existing test pins the buggy middle-phase fallback. Replace it verbatim with the new contract: unmatched-keyword features receive no inferred deps.

Find this block in `tests/spec/test_parser.py:453-462`:

```python
    def test_unmatched_features_go_to_middle_phase(self) -> None:
        features = [
            FeatureItem(category="Auth", name="login", description="login"),
            FeatureItem(category="Misc", name="misc", description="misc feature"),
            FeatureItem(category="Dashboard", name="view", description="view dashboard"),
        ]
        result = _assign_dependencies(features, ["Auth Setup", "Dashboard Build"])
        # Misc doesn't match any phase, goes to middle (index 0 for 2 phases: 2//2=1)
        # Since it goes to phase 1 (middle of 2 = index 1), it depends on phase 0
        assert result[1] == [0]
```

Replace with:

```python
    def test_unmatched_features_get_no_inferred_deps(self) -> None:
        """Unmatched-keyword category → no inferred deps (cycle-safe default).

        Replaces the legacy middle-phase fallback. See
        docs/superpowers/specs/2026-05-16-cycle-safe-dependency-inference-design.md.
        """
        features = [
            FeatureItem(category="Auth", name="login", description="login"),
            FeatureItem(category="Misc", name="misc", description="misc feature"),
            FeatureItem(category="Dashboard", name="view", description="view dashboard"),
        ]
        result = _assign_dependencies(features, ["Auth Setup", "Dashboard Build"])
        # Auth feature matches "Auth Setup" → phase 0, no predecessor
        assert result[0] == []
        # Misc category doesn't match any phase → no inferred deps (was: [0])
        assert result[1] == []
        # Dashboard matches "Dashboard Build" → phase 1, depends on phase 0 (Auth)
        assert result[2] == [0]
```

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

```bash
uv run pytest tests/spec/test_parser.py::TestDependencyAssignment::test_unmatched_features_get_no_inferred_deps -v
```

Expected: FAIL — current parser still fallbacks to middle phase, so `result[1] == [0]`, not `[]`.

---

### Task 2: Apply Change A — delete middle-phase fallback

**Files:**
- Modify: `claw_forge/spec/parser.py:642-644`

- [ ] **Step 1: Delete the fallback branch**

Find this block in `claw_forge/spec/parser.py:633-644`:

```python
    for i, feature in enumerate(features):
        assigned = False
        for p_idx, phase_title in enumerate(phases):
            phase_keywords = set(phase_title.lower().split())
            cat_keywords = set(feature.category.lower().split())
            if phase_keywords & cat_keywords:
                phase_feature_indices[p_idx].append(i)
                assigned = True
                break
        if not assigned:
            mid = len(phases) // 2
            phase_feature_indices[mid].append(i)
```

Replace with:

```python
    for i, feature in enumerate(features):
        for p_idx, phase_title in enumerate(phases):
            phase_keywords = set(phase_title.lower().split())
            cat_keywords = set(feature.category.lower().split())
            if phase_keywords & cat_keywords:
                phase_feature_indices[p_idx].append(i)
                break
        # Unmatched-keyword features: no inferred deps (cycle-safe default).
        # Declare depends_on= explicitly to give them predecessors.
```

(The `assigned` boolean and middle-phase fallback go away. The trailing comment names the contract — it's the one comment in this function that explains a non-obvious deliberate choice.)

- [ ] **Step 2: Run the failing test to confirm it now passes**

```bash
uv run pytest tests/spec/test_parser.py::TestDependencyAssignment::test_unmatched_features_get_no_inferred_deps -v
```

Expected: PASS.

- [ ] **Step 3: Run the rest of the `TestDependencyAssignment` class to confirm no regression**

```bash
uv run pytest tests/spec/test_parser.py::TestDependencyAssignment -v
```

Expected: all 5 tests pass (the rewritten one + 4 unchanged).

---

### Task 3: Add failing tests for the cycle filter (Change B)

**Files:**
- Modify: `tests/spec/test_parser.py:474` (append to `TestDependencyAssignment` class)

- [ ] **Step 1: Add two new tests at the end of `TestDependencyAssignment`**

Add this code just before the closing of `class TestDependencyAssignment` (currently around line 474, right after `test_no_mutation`):

```python
    def test_cycle_filter_drops_inferred_edge_closing_cycle(self) -> None:
        """Inferred edge that would close a cycle with an explicit edge is dropped.

        Setup: feature A (idx 0) has explicit depends_on=[1] (points forward).
        Feature B (idx 1) is in a category that would phase-infer a back-edge
        to A. The filter must drop the inferred B → A edge so the graph
        remains acyclic.
        """
        feat_a = FeatureItem(category="Foo", name="a", description="a")
        feat_a.depends_on_indices = [1]  # explicit forward edge A -> B
        feat_b = FeatureItem(category="Bar", name="b", description="b")
        features = [feat_a, feat_b]
        # Two phases: A matches "Foo", B matches "Bar". B would normally infer
        # depends on [0] (i.e., A), but that closes a cycle with the explicit
        # edge 0 -> 1. The filter must drop the inferred 1 -> 0.
        result = _assign_dependencies(features, ["Foo phase", "Bar phase"])
        assert result[0] == [1]   # explicit edge preserved
        assert result[1] == []    # inferred edge dropped (would have been [0])

    def test_cycle_filter_preserves_explicit_cycles(self) -> None:
        """User-declared explicit cycles are preserved verbatim.

        The cycle filter only drops *inferred* edges. If the user authors an
        explicit cycle, the scheduler's validate_no_cycles() catches it
        downstream with a clear error — we never silently drop user intent.
        """
        feat_a = FeatureItem(category="Foo", name="a", description="a")
        feat_a.depends_on_indices = [1]
        feat_b = FeatureItem(category="Bar", name="b", description="b")
        feat_b.depends_on_indices = [0]  # explicit cycle A <-> B
        features = [feat_a, feat_b]
        result = _assign_dependencies(features, [])  # no phases, no inference
        assert result[0] == [1]
        assert result[1] == [0]
```

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

```bash
uv run pytest tests/spec/test_parser.py::TestDependencyAssignment::test_cycle_filter_drops_inferred_edge_closing_cycle tests/spec/test_parser.py::TestDependencyAssignment::test_cycle_filter_preserves_explicit_cycles -v
```

Expected: `test_cycle_filter_drops_inferred_edge_closing_cycle` FAILS (current code keeps the inferred edge). `test_cycle_filter_preserves_explicit_cycles` should PASS already (no inference is run when `phases == []`).

---

### Task 4: Implement Change B — cycle-safe filter

**Files:**
- Modify: `claw_forge/spec/parser.py:618-651`

- [ ] **Step 1: Add the cycle-filter helper and call it from `_assign_dependencies`**

Replace the entire `_assign_dependencies` function and add a new private helper. The current function ends at line 651; locate it and replace with:

```python
def _assign_dependencies(
    features: list[FeatureItem], phases: list[str],
) -> list[list[int]]:
    """Compute dependency indices for each feature based on phase ordering.

    Returns a list parallel to *features* where each element is the list of
    ``depends_on_indices`` for that feature. The caller applies the results —
    this function is pure (no mutation).

    Contract: each feature's category words are intersected with each phase
    title's words. Features in phase N depend on all features in phase N-1.
    Features whose category matches no phase title receive **no inferred deps**
    (cycle-safe default; declare ``depends_on=`` explicitly to give them
    predecessors).

    A defense-in-depth filter then drops any inferred edge that would close a
    cycle when unioned with the user's explicit ``depends_on_indices``.
    Explicit edges are never dropped — if the user authors an explicit cycle,
    ``Scheduler.validate_no_cycles()`` catches it downstream with a clearer
    error.
    """
    result: list[list[int]] = [list(f.depends_on_indices) for f in features]
    if not phases:
        return result

    phase_feature_indices: list[list[int]] = [[] for _ in phases]

    for i, feature in enumerate(features):
        for p_idx, phase_title in enumerate(phases):
            phase_keywords = set(phase_title.lower().split())
            cat_keywords = set(feature.category.lower().split())
            if phase_keywords & cat_keywords:
                phase_feature_indices[p_idx].append(i)
                break
        # Unmatched-keyword features: no inferred deps (cycle-safe default).
        # Declare depends_on= explicitly to give them predecessors.

    for p_idx in range(1, len(phases)):
        prev_phase_indices = phase_feature_indices[p_idx - 1]
        for feat_idx in phase_feature_indices[p_idx]:
            result[feat_idx] = list(prev_phase_indices)

    return _filter_inferred_edges_breaking_cycles(features, result)


def _filter_inferred_edges_breaking_cycles(
    features: list[FeatureItem],
    result: list[list[int]],
) -> list[list[int]]:
    """Drop inferred edges in *result* that would close a cycle.

    An *inferred* edge is one present in ``result[i]`` but not in
    ``features[i].depends_on_indices`` (the explicit set). Explicit edges
    are never dropped.

    Algorithm: start with the explicit-only graph (which may itself be
    cyclic — the scheduler reports that separately). For each inferred
    edge ``(i, dep)`` in stable order (feature index ascending, then dep
    index ascending): if ``dep`` can already reach ``i`` in the current
    graph, adding ``i → dep`` would close a cycle, so drop the edge.
    Otherwise add it.

    Complexity: O(N · E) DFS per inferred edge, acceptable for ≤ ~1000
    features.
    """
    n = len(features)
    explicit: list[set[int]] = [set(f.depends_on_indices) for f in features]
    graph: list[set[int]] = [set(s) for s in explicit]

    def reaches(src: int, dst: int) -> bool:
        if src == dst:
            return True
        seen: set[int] = {src}
        stack: list[int] = list(graph[src])
        while stack:
            node = stack.pop()
            if node == dst:
                return True
            if node in seen:
                continue
            seen.add(node)
            stack.extend(graph[node])
        return False

    for i in range(n):
        for dep in sorted(set(result[i]) - explicit[i]):
            if reaches(dep, i):
                continue  # drop: adding i -> dep would close a cycle
            graph[i].add(dep)

    return [sorted(graph[i]) for i in range(n)]
```

- [ ] **Step 2: Run the new tests to confirm they pass**

```bash
uv run pytest tests/spec/test_parser.py::TestDependencyAssignment -v
```

Expected: all tests in the class pass (the two new + the rewritten + 3 unchanged).

- [ ] **Step 3: Run the full parser test module to confirm no regression**

```bash
uv run pytest tests/spec/test_parser.py -v
```

Expected: all tests pass.

---

### Task 5: Add integration test for Meridian-shape cycle reproducer

**Files:**
- Modify: `tests/spec/test_parser.py` (append new test class at end of file)

- [ ] **Step 1: Add the integration test**

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

```python
class TestMeridianShapeRegression:
    """Reproduces the v0.8.31 CycleDetectedError regression on a minimal XML.

    See docs/superpowers/specs/2026-05-16-cycle-safe-dependency-inference-design.md.
    The Meridian spec had a Storage feature with no explicit depends_on whose
    category ("Storage & Repository Pattern") matched no phase title and so
    fell through to the middle-phase fallback. Lifecycle Hooks features in
    the prev-of-middle phase then transitively depended on it via explicit
    depends_on chains, closing a cycle.
    """

    def test_storage_no_deps_plus_lifecycle_back_edge_no_cycle(self) -> None:
        from claw_forge.spec.parser import ProjectSpec
        from claw_forge.state.scheduler import Scheduler, TaskNode

        xml = """<?xml version="1.0" encoding="UTF-8"?>
<project_specification mode="greenfield">
  <project_name>regression</project_name>
  <overview>minimal reproducer</overview>
  <features>
    <category name="Storage &amp; Repository Pattern">
      <feature index="1" shape="core" touches_files="src/storage.py">
        <description>System NDJSON event log writer appends one JSON line per event</description>
      </feature>
    </category>
    <category name="Sessions &amp; Event Log">
      <feature index="2" shape="plugin" plugin="sessions" depends_on="1">
        <description>System SessionEvent type union references the event log</description>
      </feature>
    </category>
    <category name="Lifecycle Hooks">
      <feature index="3" shape="plugin" plugin="hooks" depends_on="2">
        <description>API POST /v1/x/hooks registers a HookBinding</description>
      </feature>
    </category>
  </features>
  <implementation_steps>
    <phase name="E0 — Skeleton">setup</phase>
    <phase name="E1 — Hooks Phase">hooks work</phase>
  </implementation_steps>
</project_specification>
"""
        spec = ProjectSpec.from_xml(xml)
        # Build scheduler and assert no cycle
        scheduler = Scheduler()
        for i, feat in enumerate(spec.features):
            scheduler.add_task(TaskNode(
                id=str(i),
                plugin_name="coding",
                priority=0,
                depends_on=[str(j) for j in feat.depends_on_indices],
            ))
        # If the fix works, validate_no_cycles is a no-op.
        scheduler.validate_no_cycles()
```

- [ ] **Step 2: Run the integration test**

```bash
uv run pytest tests/spec/test_parser.py::TestMeridianShapeRegression -v
```

Expected: PASS (with the parser fix in place).

---

### Task 6: Commit the parser fix

- [ ] **Step 1: Lint and type-check**

```bash
uv run ruff check claw_forge/ tests/
uv run mypy claw_forge/ --ignore-missing-imports
```

Expected: no errors.

- [ ] **Step 2: Commit**

```bash
git add claw_forge/spec/parser.py tests/spec/test_parser.py
git commit -m "$(cat <<'EOF'
fix(spec): cycle-safe dependency inference in _assign_dependencies

Remove the middle-phase fallback that placed unmatched-keyword features
into phases[len(phases)//2], creating arbitrary deps to whatever sat in
the predecessor phase. Add a defense-in-depth filter that drops any
inferred edge that would close a cycle with the user's explicit
<feature depends_on> edges. Explicit edges are never dropped.

Resolves the CycleDetectedError regression on specs that mix explicit
and inferred edges. See
docs/superpowers/specs/2026-05-16-cycle-safe-dependency-inference-design.md.

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

---

## Phase B — Validator Gap 9 (Tasks 7-12)

### Task 7: Add Gap 9 base test

**Files:**
- Modify: `tests/spec/test_validator.py` (append)

- [ ] **Step 1: Add the base Gap 9 test**

Append to `tests/spec/test_validator.py` (find an appropriate spot near the other `check_*` tests, or append to the end if they're grouped per-check):

```python
class TestGap9UnmatchedPhaseInference:
    """Gap 9 — feature in unmatched-keyword category with no explicit deps.

    See docs/superpowers/specs/2026-05-16-cycle-safe-dependency-inference-design.md.
    """

    def _make_spec(
        self,
        *,
        category: str,
        explicit_deps: list[int] | None = None,
        phases: list[str],
    ) -> "ProjectSpec":
        """Build a minimal ProjectSpec with one feature for Gap 9 testing."""
        from claw_forge.spec.parser import FeatureItem, ProjectSpec, TechStack
        feat = FeatureItem(
            category=category, name="test", description="test feature",
        )
        if explicit_deps is not None:
            feat.depends_on_indices = explicit_deps
        return ProjectSpec(
            project_name="t",
            overview="",
            target_audience="",
            constraints=[],
            tech_stack=TechStack(),
            features=[feat],
            implementation_phases=phases,
            success_criteria=[],
            design_system={},
            api_endpoints={},
            database_tables={},
            raw_xml="",
        )

    def test_warning_when_category_matches_no_phase(self) -> None:
        from claw_forge.spec.validator import (
            IssueSeverity, check_unmatched_phase_inference,
        )
        spec = self._make_spec(
            category="Storage & Repository Pattern",  # no phase keyword overlap
            explicit_deps=None,
            phases=["E0 Skeleton", "E1 Sandbox"],
        )
        issue = check_unmatched_phase_inference(
            spec.features[0], spec.implementation_phases, strict=False,
        )
        assert issue is not None
        assert issue.severity == IssueSeverity.WARNING
        assert issue.layer == 4
        assert "Storage" in issue.category
        assert "no explicit depends_on" in issue.message
        assert "phase" in issue.message.lower()
```

- [ ] **Step 2: Run the new test to verify it fails**

```bash
uv run pytest tests/spec/test_validator.py::TestGap9UnmatchedPhaseInference::test_warning_when_category_matches_no_phase -v
```

Expected: FAIL — `check_unmatched_phase_inference` doesn't exist yet.

---

### Task 8: Implement `check_unmatched_phase_inference`

**Files:**
- Modify: `claw_forge/spec/validator.py` (add new function near the other Gap checks)

- [ ] **Step 1: Add the function and export it**

Find the `__all__` list at the top of `claw_forge/spec/validator.py:16-45` and add `"check_unmatched_phase_inference"` to it alphabetically:

```python
__all__ = [
    "CORE_CHAIN_DEPTH_THRESHOLD",
    "CROSS_CUTTING_INDICATORS",
    "DEFAULT_MIGRATION_TOUCHES_FILES",
    "IssueSeverity",
    "MIGRATION_INDICATORS",
    "VERTICAL_INDICATORS",
    "VERTICAL_MIN_BULLETS",
    "VERTICAL_RATIO_THRESHOLD",
    "ValidationIssue",
    "ValidationReport",
    "_slugify_plugin_name",
    "check_category_looks_vertical",
    "check_core_chain_depth",
    "check_plugin_dir_exists",
    "check_has_measurable_outcome",
    "check_plugin_shape_complete",
    "check_migration_shape_candidate",
    "check_touches_overlap",
    "check_unmatched_phase_inference",
    "check_is_atomic",
    "check_not_too_vague",
    "check_starts_with_action_verb",
    "run_coverage_checks",
    "run_shape_checks",
    "run_structural_checks",
    "SpecEvaluator",
    "SPEC_DIMENSIONS",
    "run_llm_evaluation",
    "validate_spec",
]
```

Add the new check function. Insert immediately after `check_plugin_dir_exists` (currently ending around `validator.py:995`):

```python
def check_unmatched_phase_inference(
    feat: "FeatureItem",
    phases: list[str],
    *,
    strict: bool,
) -> ValidationIssue | None:
    """Gap 9 — feature in unmatched-keyword category with no explicit deps.

    Fires when the spec has ≥2 implementation phases AND the feature has no
    explicit ``depends_on=`` AND the feature's category words don't intersect
    any phase title. Under the cycle-safe default these features get no
    inferred deps — surfacing the situation lets the user declare predecessors
    explicitly (or rename a phase title to include a category keyword).

    WARNING by default, ERROR under ``--strict-shape``.
    """
    if len(phases) < 2:
        return None
    if feat.depends_on_indices:
        return None  # explicit deps short-circuit inference
    cat_keywords = set(feat.category.lower().split())
    for phase_title in phases:
        if set(phase_title.lower().split()) & cat_keywords:
            return None  # category matches a phase; inference runs normally
    severity = IssueSeverity.ERROR if strict else IssueSeverity.WARNING
    return ValidationIssue(
        severity=severity,
        layer=4,
        category=feat.category,
        bullet=feat.description,
        message=(
            f"feature has no explicit depends_on= and category "
            f"{feat.category!r} shares no keywords with any "
            f"<implementation_steps> phase title — inferred deps are "
            f"disabled (cycle-safe default)."
        ),
        suggestion=(
            "Declare depends_on=\"<prev-index-in-same-category>\" "
            "explicitly, or rename a phase title to include a category "
            "keyword. If this is a foundation feature with no predecessors, "
            "leave as-is."
        ),
    )
```

- [ ] **Step 2: Run the base Gap 9 test to confirm it passes**

```bash
uv run pytest tests/spec/test_validator.py::TestGap9UnmatchedPhaseInference::test_warning_when_category_matches_no_phase -v
```

Expected: PASS.

---

### Task 9: Wire Gap 9 into `run_shape_checks` and `validate_spec`

**Files:**
- Modify: `claw_forge/spec/validator.py:998-1065` (`run_shape_checks`)
- Modify: `claw_forge/spec/validator.py:1068-1128` (`validate_spec`)

- [ ] **Step 1: Thread `phases` through `run_shape_checks`**

Find `run_shape_checks` (starts at `validator.py:998`). Update its signature and add the Gap 9 loop. Replace:

```python
def run_shape_checks(
    spec: ProjectSpec,
    *,
    project_root: Path | None = None,
    strict: bool = False,
    allow_soft_migration_shape: bool = False,
) -> ValidationReport:
```

with:

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

    Composes seven check functions:
      - check_plugin_shape_complete (Gap 1; per-feature; always ERROR)
      - check_migration_shape_candidate (Gap 8; per-feature; ERROR by
        default — see ``allow_soft_migration_shape``)
      - 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)
      - check_unmatched_phase_inference (Gap 9; per-feature; WARN/ERR strict)

    Filesystem checks skip when project_root is None.  Brownfield is
    detected by the presence of src/plugins/ under project_root.
    """
```

Then, in the body of the function, find the per-feature loop (currently at `validator.py:1028-1038`) and add the Gap 9 check inside it. The current block looks like:

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

Replace with:

```python
    # Per-feature checks
    for feat in spec.features:
        issue = check_plugin_shape_complete(feat, strict=strict)
        if issue is not None:
            issues.append(issue)
        issue = check_migration_shape_candidate(
            feat,
            strict=strict,
            allow_soft=allow_soft_migration_shape,
        )
        if issue is not None:
            issues.append(issue)
        issue = check_unmatched_phase_inference(
            feat, spec.implementation_phases, strict=strict,
        )
        if issue is not None:
            issues.append(issue)
```

- [ ] **Step 2: No change needed to `validate_spec`**

`validate_spec` already calls `run_shape_checks(spec, ...)` and `spec.implementation_phases` is already populated by the parser. No signature change needed.

- [ ] **Step 3: Run the base Gap 9 test through the full pipeline**

```bash
uv run pytest tests/spec/test_validator.py::TestGap9UnmatchedPhaseInference -v
```

Expected: PASS.

---

### Task 10: Add tests for strict mode, quiet cases, and the empty-phases case

**Files:**
- Modify: `tests/spec/test_validator.py` (append to `TestGap9UnmatchedPhaseInference`)

- [ ] **Step 1: Add the four additional tests**

Append within `class TestGap9UnmatchedPhaseInference`:

```python
    def test_promoted_to_error_under_strict_shape(self) -> None:
        from claw_forge.spec.validator import (
            IssueSeverity, check_unmatched_phase_inference,
        )
        spec = self._make_spec(
            category="Storage & Repository Pattern",
            explicit_deps=None,
            phases=["E0 Skeleton", "E1 Sandbox"],
        )
        issue = check_unmatched_phase_inference(
            spec.features[0], spec.implementation_phases, strict=True,
        )
        assert issue is not None
        assert issue.severity == IssueSeverity.ERROR

    def test_quiet_when_feature_has_explicit_depends_on(self) -> None:
        from claw_forge.spec.validator import check_unmatched_phase_inference
        spec = self._make_spec(
            category="Storage & Repository Pattern",
            explicit_deps=[0],  # explicit deps short-circuit
            phases=["E0 Skeleton", "E1 Sandbox"],
        )
        assert check_unmatched_phase_inference(
            spec.features[0], spec.implementation_phases, strict=False,
        ) is None

    def test_quiet_when_no_implementation_steps(self) -> None:
        from claw_forge.spec.validator import check_unmatched_phase_inference
        spec = self._make_spec(
            category="Storage & Repository Pattern",
            explicit_deps=None,
            phases=[],  # no phases → no inference would run
        )
        assert check_unmatched_phase_inference(
            spec.features[0], spec.implementation_phases, strict=False,
        ) is None

    def test_quiet_when_only_one_phase(self) -> None:
        from claw_forge.spec.validator import check_unmatched_phase_inference
        spec = self._make_spec(
            category="Storage & Repository Pattern",
            explicit_deps=None,
            phases=["E0 Skeleton"],  # single phase → no inference anyway
        )
        assert check_unmatched_phase_inference(
            spec.features[0], spec.implementation_phases, strict=False,
        ) is None

    def test_quiet_when_category_matches_a_phase(self) -> None:
        from claw_forge.spec.validator import check_unmatched_phase_inference
        spec = self._make_spec(
            category="Lifecycle Hooks",  # matches "hooks" in phase 2
            explicit_deps=None,
            phases=["E0 Skeleton", "E1 Hooks Phase"],
        )
        assert check_unmatched_phase_inference(
            spec.features[0], spec.implementation_phases, strict=False,
        ) is None
```

- [ ] **Step 2: Run all Gap 9 tests**

```bash
uv run pytest tests/spec/test_validator.py::TestGap9UnmatchedPhaseInference -v
```

Expected: 6 PASS (1 from Task 7 + 5 new).

---

### Task 11: Commit the validator addition

- [ ] **Step 1: Lint and type-check**

```bash
uv run ruff check claw_forge/ tests/
uv run mypy claw_forge/ --ignore-missing-imports
```

Expected: no errors.

- [ ] **Step 2: Run the full validator test module**

```bash
uv run pytest tests/spec/test_validator.py -v
```

Expected: all tests pass.

- [ ] **Step 3: Commit**

```bash
git add claw_forge/spec/validator.py tests/spec/test_validator.py
git commit -m "$(cat <<'EOF'
feat(validator): Gap 9 — unmatched-phase inference detection

Adds check_unmatched_phase_inference to Layer 4. Surfaces features whose
category shares no keywords with any <implementation_steps> phase title
and which have no explicit depends_on= — under the cycle-safe default
these features receive no inferred deps, and the user may have wanted
predecessors. WARNING default, ERROR under --strict-shape.

See docs/superpowers/specs/2026-05-16-cycle-safe-dependency-inference-design.md.

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

---

## Phase C — `/fix-spec` rule (Task 12)

### Task 12: Extend `/fix-spec` with Gap 9 fix rule

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

- [ ] **Step 1: Add the Gap 9 row to the Layer 4 fix table**

Find the table header in `.claude/commands/fix-spec.md` (currently around line 126-131):

```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 8: feature description suggests migration / DB-schema work, not declared `shape="core"` | WARNING / ERROR strict | Replace the existing `shape=`/`plugin=`/`touches_files=` attributes with `shape="core" touches_files="migrations/versions/**"`. Migration-touching features mutate alembic's revision tree (a global shared resource) — `shape="core"` makes the dispatcher single-flight them, preventing parallel agents from writing duplicate `revision="N"` migrations. |
| Gap 4 (typo): handled in Step 0 | (parse-time) | See Step 0. |
```

Append a new row before the Gap 4 row:

```markdown
| Gap 9: feature has no explicit `depends_on=` and category doesn't match any phase title | WARNING / ERROR strict | If the feature is **not** first in its category: add `depends_on="<index-of-prev-feature-in-same-category>"` to the `<feature>` element. If the feature **is** first in its category (no predecessor in same category): route to the Manual Review block — the user must confirm it's a foundation feature with no predecessors. |
```

- [ ] **Step 2: Append the worked Gap 9 example**

Find the existing "Examples:" block below the Layer 4 table (the Gap 1 / Gap 6 / Gap 8 examples around lines 134-178). Append a new example block after the Gap 8 example:

```markdown
```
# BEFORE (Gap 9 — WARNING; Storage category shares no keywords with any phase):
<category name="Storage &amp; Repository Pattern">
  <feature index="35" shape="core" touches_files="src/storage/vec.py" depends_on="30">
    <description>System sqlite-vec extension loaded at connection time</description>
  </feature>
  <feature index="36" shape="core" touches_files="src/storage/event_log.py">
    <description>System NDJSON event log writer appends one JSON line per event</description>
  </feature>
</category>

# AFTER (prev feature in same category is index 35):
<category name="Storage &amp; Repository Pattern">
  <feature index="35" shape="core" touches_files="src/storage/vec.py" depends_on="30">
    <description>System sqlite-vec extension loaded at connection time</description>
  </feature>
  <feature index="36" shape="core" touches_files="src/storage/event_log.py" depends_on="35">
    <description>System NDJSON event log writer appends one JSON line per event</description>
  </feature>
</category>
```
```

- [ ] **Step 3: Add Gap 9 first-in-category case to Manual Review block**

Find the `Issue types that go here` table in the Manual Review section (around line 232). Append a row:

```markdown
| Gap 9: feature is first in its category with no explicit depends_on= | Foundation feature vs cross-category predecessor is domain knowledge |
```

Then append a worked Manual Review entry within the example block (around line 244, after the existing "Example block" examples):

```markdown
  4. [Storage & Repository Pattern] feature 29 is first in its category with no explicit depends_on=
     Feature: "System Repository abstract base classes define one interface per resource..."
     Question: should this depend on any prior-category foundation feature, or is it a true foundation?
     Candidate fixes:
       a) Declare it a foundation feature — leave depends_on absent (the cycle-safe default
          gives it no predecessors)
       b) Rename a phase title to include a category keyword like "storage" or "repository"
          so phase inference picks it up
       c) Add cross-category depends_on= pointing at an appropriate predecessor
          (e.g. a config-loader feature index)
```

- [ ] **Step 4: Update the "Gaps that cannot be auto-fixed" comment**

Find the comment block in `.claude/commands/fix-spec.md` (around line 181):

```markdown
**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.
```

Change to:

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

- [ ] **Step 5: Commit**

```bash
git add .claude/commands/fix-spec.md
git commit -m "$(cat <<'EOF'
feat(fix-spec): Gap 9 auto-fix rule for unmatched-phase inference

Adds a Layer 4 rule for the new Gap 9 surfaced by check_unmatched_phase_inference.
Non-first-in-category features get depends_on="<prev-in-category>" auto-added;
first-in-category features route to the Manual Review block with three
candidate fixes.

See docs/superpowers/specs/2026-05-16-cycle-safe-dependency-inference-design.md.

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

---

## Phase D — Docs (Task 13)

### Task 13: Update CLAUDE.md

**Files:**
- Modify: `CLAUDE.md` (two sections)

- [ ] **Step 1: Update the `spec/parser.py` section**

In CLAUDE.md, find the `### spec/parser.py` section. Locate the paragraph describing dependency assignment (currently a few paragraphs in). The current text doesn't explicitly describe `_assign_dependencies` semantics — find an appropriate spot near the discussion of `<feature depends_on>` resolution and `_assign_dependencies` (look for the comment `### spec/parser.py` followed by paragraphs about parsing).

Append this paragraph to the `spec/parser.py` section:

```markdown
**Dependency inference contract** (v0.8.32+): `_assign_dependencies`
assigns each feature to an `<implementation_steps>` phase by
case-insensitive word-overlap between the feature's category name and the
phase title. Features in phase N get inferred `depends_on` = all features
in phase N-1. **Features whose category words don't intersect any phase
title receive no inferred deps** (cycle-safe default — replaced the legacy
middle-phase fallback that produced silent cross-phase cycles when combined
with explicit `<feature depends_on>` edges). Declare `depends_on=`
explicitly for foundation features, or rename a phase title to include a
category keyword. A defense-in-depth filter at the end of
`_assign_dependencies` drops any inferred edge that would close a cycle
with the user's explicit edges; explicit edges are never dropped (the
scheduler's `validate_no_cycles()` catches user-authored cycles downstream
with a clearer error).
```

- [ ] **Step 2: Update the Layer 4 gap list**

Find the `### spec/validator.py — Layer 4 (Shape)` section in CLAUDE.md. After the existing `Gap 8` bullet, append:

```markdown
- **Gap 9** (WARNING / ERROR strict): feature has no explicit
  `depends_on=` and its category shares no keywords with any
  `<implementation_steps>` phase title. Under the cycle-safe default
  these features get no inferred deps — the validator surfaces them so
  the user can declare predecessors explicitly. `/fix-spec` auto-fixes
  by adding `depends_on="<prev-index-in-same-category>"` for non-first
  features; first-in-category features route to Manual Review.
```

- [ ] **Step 3: Commit**

```bash
git add CLAUDE.md
git commit -m "$(cat <<'EOF'
docs: cycle-safe dependency inference + Gap 9 in CLAUDE.md

Documents the new _assign_dependencies contract (no inferred deps for
unmatched-keyword categories; cycle-safe filter as defense in depth)
and the new Layer 4 Gap 9 validator check.

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

---

## Phase E — End-to-end verification (Tasks 14-15)

### Task 14: Full test suite + lint + mypy + coverage

- [ ] **Step 1: Run the full test suite**

```bash
uv run pytest tests/ -q
```

Expected: all tests pass.

- [ ] **Step 2: Run lint over both source and tests**

```bash
uv run ruff check claw_forge/ tests/
```

Expected: no errors.

- [ ] **Step 3: Run mypy**

```bash
uv run mypy claw_forge/ --ignore-missing-imports
```

Expected: no errors.

- [ ] **Step 4: Verify coverage stays ≥ 90%**

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

Expected: coverage ≥ 90% (CI gate).

---

### Task 15: Reproduce the Meridian fix end-to-end

- [ ] **Step 1: Run `validate-spec --strict-shape` on the Meridian spec from this worktree**

```bash
uv run claw-forge validate-spec --strict-shape /Users/bowenli/development/meridian/app_spec.txt 2>&1 | tail -40
```

Expected: Layer 4 reports Gap 9 ERRORs for each unmatched-keyword feature with no explicit `depends_on=` (notably Storage feature 36 and Storage feature 40, plus any others matching the pattern in other categories). Layer 1-3 unchanged. The overall report still passes structurally; only Layer 4 strict-mode errors fire.

- [ ] **Step 2: Run `claw-forge plan` on the Meridian spec and verify no cycle**

```bash
cd /Users/bowenli/development/meridian
rm -f .claw-forge/state.db .claw-forge/state.db-wal .claw-forge/state.db-shm
uv run --project /Users/bowenli/development/claw-forge/.claude/worktrees/activity-log-slot-regression \
    claw-forge plan app_spec.txt 2>&1 | tail -20
```

Expected: no `CycleDetectedError`. Plan succeeds and writes 273 tasks to state.db.

- [ ] **Step 3: Confirm Storage feature 36 now has no inferred deps**

```bash
uv run --project /Users/bowenli/development/claw-forge/.claude/worktrees/activity-log-slot-regression \
    python -c "
import sqlite3, json
con = sqlite3.connect('/Users/bowenli/development/meridian/.claw-forge/state.db')
row = con.execute(\"SELECT depends_on FROM tasks WHERE description LIKE 'System NDJSON event log writer%' LIMIT 1\").fetchone()
print('depends_on:', json.loads(row[0]))
"
```

Expected: `depends_on: []` (was 14 phantom deps before the fix).

- [ ] **Step 4: Restore Meridian to a clean state**

```bash
cd /Users/bowenli/development/meridian
rm -f .claw-forge/state.db .claw-forge/state.db-wal .claw-forge/state.db-shm
```

(The user can re-run `claw-forge plan` themselves to confirm; we don't leave a half-populated DB behind.)

---

## Finishing

After all tasks complete:

- All five commits land on the worktree branch (parser fix, validator addition, /fix-spec rule, docs, plus the spec doc commit from earlier).
- The Meridian spec planning no longer crashes.
- `/fix-spec` will auto-suggest `depends_on=` for Storage feature 36 and similar cases the next time the user runs it.
- Coverage ≥ 90%.

Use the `superpowers:finishing-a-development-branch` skill to decide between merging to main, opening a PR, or further iteration.

---

## Self-Review

**Spec coverage check** (against `docs/superpowers/specs/2026-05-16-cycle-safe-dependency-inference-design.md`):

| Spec section | Plan task(s) |
|---|---|
| Decision 1 (remove middle-phase fallback) | Tasks 1-2 |
| Decision 2 (cycle filter) | Tasks 3-4 |
| Decision 3 (Gap 9 + /fix-spec) | Tasks 7-12 |
| Component 1 (parser changes) | Tasks 1-6 |
| Component 2 (validator) | Tasks 7-11 |
| Component 3 (/fix-spec) | Task 12 |
| Component 4 (tests) | Tasks 1, 3, 5, 7, 10 |
| Component 5 (docs) | Task 13 |
| Success criterion 1 (Meridian no cycle) | Task 15 step 2 |
| Success criterion 2 (rewritten test passes) | Task 2 |
| Success criterion 3 (new tests pass) | Tasks 5, 10, 14 |
| Success criterion 4 (coverage ≥ 90%) | Task 14 |
| Success criterion 5 (Gap 9 ERROR on Meridian) | Task 15 step 1 |
| Success criterion 6 (/fix-spec dry-run) | Skipped — `/fix-spec` runs interactively through Claude Code and isn't easily scriptable. Task 12 documents the rule; manual verification is at user's discretion. |

**Placeholder scan:** none found.

**Type consistency:** `_filter_inferred_edges_breaking_cycles` returns `list[list[int]]`; `_assign_dependencies` calls it as the final return. `check_unmatched_phase_inference` returns `ValidationIssue | None` matching the other Gap-check functions. `run_shape_checks` signature unchanged externally; internal loop reads `spec.implementation_phases` which is already populated.
