# Structural Conflict Elimination 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:** Drive plugin-vs-plugin merge conflicts toward zero by (A) letting footprint-disjoint `core` tasks run in parallel instead of blanket single-flighting, (B) detecting + discouraging shared-registration-file edits so plugin features become truly self-contained, and (C) tightening the OS sandbox fence from the whole worktree down to a plugin feature's declared footprint.

**Architecture:** Three phases landed in dependency order. **Phase A** (scheduler) is independent and ships first as a low-risk win. **Phase B** (validator Gap 11 + auto-discovery directive) must precede **Phase C** (sandbox fence) — the fence makes out-of-footprint writes fail with `EACCES`, so we must first remove the *need* for plugin tasks to touch shared files (B) before removing the *ability* (C). The fence ships opt-in (`agent.sandbox.fence_to_touches_files: false`) until its false-positive rate is known.

**Tech Stack:** Python 3.12, `asyncio`, Typer CLI, pytest (90% branch-coverage gate), `ruff`, `mypy`. macOS `sandbox-exec` profiles + Linux `bwrap` for OS-level isolation.

---

## File Structure

| File | Responsibility | Phase |
|---|---|---|
| `claw_forge/state/scheduler.py` | Modify `get_ready_tasks` — boundary-aware core concurrency | A |
| `tests/test_scheduler.py` | New `TestBoundaryAwareCore` test class | A |
| `claw_forge/spec/validator.py` | New `check_plugin_touches_escape` (Gap 11) + wire into `run_shape_checks` | B |
| `tests/spec/test_validator_shape.py` | New `TestPluginTouchesEscape` test class | B |
| `claw_forge/orchestrator/task_handler.py` | Append auto-discovery directive to plugin-shape system prompts | B |
| `claw_forge/agent/sandbox.py` | Add `writable_subpaths` to profile builders + `make_sandbox_wrapper`; new `derive_plugin_writable_roots` helper | C |
| `tests/agent/test_sandbox.py` | New `TestWritableSubpathFence` test class | C |
| `claw_forge/orchestrator/task_handler.py` | Derive writable roots from task shape/touches; pass to `make_sandbox_wrapper` | C |
| `claw_forge/orchestrator/run_cli.py` | Read `agent.sandbox.fence_to_touches_files` config → `RunContext` | C |
| `claw_forge/config.py` | Scaffold the new flag in the default yaml | C |
| `CLAUDE.md`, `docs/commands.md`, `README.md` | Document Gap 11, boundary-aware core, the fence flag | A/B/C |

**Key invariants to preserve:**
- The nested-sandbox landmine (CLAUDE.md): when Layer 4 is active, SDK `SandboxSettings.enabled` MUST stay `False`. Phase C does not touch that wiring — it only narrows *which subpaths* the existing wrapper allows.
- All sandbox paths MUST be `Path.resolve()`'d (macOS canonical-path matching; Linux bwrap symlink-at-mount).
- Coverage gate is 90% branch — every new function needs a test.

---

## Phase A — Boundary-aware core concurrency (scheduler)

**Context for the engineer:** `Scheduler.get_ready_tasks` (`scheduler.py:61-103`) currently single-flights ALL `shape="core"` tasks: if any core task is running, every other core task is dropped from the ready set, even when their `touches_files` are completely disjoint. We replace the blunt "any core running → drop all core" with a footprint-overlap check: a core candidate is blocked only if its declared `touches_files` overlap a running task's, OR it declares no footprint at all (can't prove disjoint → stay conservative). Migration tasks already declare the shared `migrations/versions/**` glob (validator Gap 8), so two of them overlap on that string and still serialize — the desired behavior falls out for free.

### Task A1: Boundary-aware core ready-set filter

**Files:**
- Modify: `claw_forge/state/scheduler.py:89-98`
- Test: `tests/test_scheduler.py` (new `TestBoundaryAwareCore` class)

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

Append to `tests/test_scheduler.py`:

```python
class TestBoundaryAwareCore:
    def _running(self, node: TaskNode) -> TaskNode:
        node.status = "running"
        return node

    def test_disjoint_core_tasks_both_ready(self):
        """Two core tasks with disjoint touches_files may run in parallel."""
        s = Scheduler()
        s.add_task(self._running(
            TaskNode("a", "coding", 1, [], shape="core",
                     touches_files=["src/core/cache.py"])))
        s.add_task(
            TaskNode("b", "coding", 1, [], shape="core",
                     touches_files=["src/core/logging.py"]))
        ready = s.get_ready_tasks()
        assert [t.id for t in ready] == ["b"]

    def test_overlapping_core_tasks_serialize(self):
        """Core tasks sharing a touches_files glob still single-flight."""
        s = Scheduler()
        s.add_task(self._running(
            TaskNode("a", "coding", 1, [], shape="core",
                     touches_files=["migrations/versions/**"])))
        s.add_task(
            TaskNode("b", "coding", 1, [], shape="core",
                     touches_files=["migrations/versions/**"]))
        ready = s.get_ready_tasks()
        assert ready == []

    def test_footprintless_core_stays_single_flight(self):
        """A core task with no touches_files can't prove disjoint → blocked."""
        s = Scheduler()
        s.add_task(self._running(
            TaskNode("a", "coding", 1, [], shape="core", touches_files=[])))
        s.add_task(TaskNode("b", "coding", 1, [], shape="core",
                            touches_files=[]))
        ready = s.get_ready_tasks()
        assert ready == []

    def test_core_blocked_by_overlapping_plugin(self):
        """A core candidate overlapping a running plugin task is blocked."""
        s = Scheduler()
        s.add_task(self._running(
            TaskNode("a", "coding", 1, [], shape="plugin",
                     touches_files=["src/plugins/auth/**"])))
        s.add_task(
            TaskNode("b", "coding", 1, [], shape="core",
                     touches_files=["src/plugins/auth/**"]))
        ready = s.get_ready_tasks()
        assert ready == []

    def test_plugin_tasks_unaffected(self):
        """Plugin tasks still dispatch freely regardless of running core."""
        s = Scheduler()
        s.add_task(self._running(
            TaskNode("a", "coding", 1, [], shape="core",
                     touches_files=["src/core/db.py"])))
        s.add_task(
            TaskNode("b", "coding", 1, [], shape="plugin",
                     touches_files=["src/plugins/billing/**"]))
        ready = s.get_ready_tasks()
        assert [t.id for t in ready] == ["b"]
```

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

Run: `uv run pytest tests/test_scheduler.py::TestBoundaryAwareCore -v`
Expected: `test_disjoint_core_tasks_both_ready` FAILS (current code drops all core when any core runs, so `b` is filtered out → `ready == []`). Others may pass incidentally.

- [ ] **Step 3: Replace the single-flight block**

In `claw_forge/state/scheduler.py`, replace lines 89-98 (the `any_core_running` block) with:

```python
        # Cross-cutting (shape="core") tasks no longer blanket single-flight.
        # A core candidate is dropped only when it cannot be PROVEN disjoint
        # from a currently-running task:
        #   * its touches_files overlap a running task's touches_files, OR
        #   * it declares no touches_files at all (can't prove disjoint) and
        #     another core task is running.
        # File-claim locks in the dispatcher remain the authoritative
        # collision guard; this filter just stops the scheduler from
        # over-serialising genuinely-disjoint cross-cutting work.  Migration
        # tasks declare the shared "migrations/versions/**" glob (validator
        # Gap 8), so two of them overlap on that string and still serialise.
        running = [t for t in self._tasks.values() if t.status == "running"]
        if running:
            def _core_blocked(cand: TaskNode) -> bool:
                cand_fp = set(cand.touches_files)
                for r in running:
                    if r.id == cand.id:
                        continue
                    if not cand_fp:
                        if r.shape == "core":
                            return True  # footprintless core ↔ running core
                        continue
                    if cand_fp & set(r.touches_files):
                        return True
                return False

            ready = [t for t in ready if t.shape != "core" or not _core_blocked(t)]
```

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

Run: `uv run pytest tests/test_scheduler.py::TestBoundaryAwareCore -v`
Expected: PASS (all 5)

- [ ] **Step 5: Run the full scheduler suite for regressions**

Run: `uv run pytest tests/test_scheduler.py -v`
Expected: PASS. If a pre-existing shape single-flight test asserted the old blanket behavior (two disjoint core tasks serializing), update it to the boundary-aware expectation and note the change in its docstring.

- [ ] **Step 6: Lint + commit**

```bash
uv run ruff check claw_forge/state/scheduler.py tests/test_scheduler.py
git add claw_forge/state/scheduler.py tests/test_scheduler.py
git commit -m "$(cat <<'EOF'
feat(scheduler): boundary-aware core concurrency

Replace blanket shape="core" single-flight with a footprint-overlap
check — disjoint cross-cutting tasks now run in parallel; overlapping
(incl. migration tasks sharing migrations/versions/**) still serialise.
File-claim locks remain the authoritative collision guard.

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

---

## Phase B — Auto-discovery: remove the need to touch shared files

**Context for the engineer:** A plugin feature should be self-contained in its own directory. When a `shape="plugin"` feature declares `touches_files` reaching *outside* its plugin dir (e.g. into a central `router.py` or `pyproject.toml` to register itself), that shared edit is where parallel agents collide — and it's the one thing the Phase C fence will forbid. Phase B adds (B1) a validator warning that detects the escape and points to auto-discovery, and (B2) a system-prompt directive telling plugin agents to register by convention. B1 is the precondition that proves the Phase C fence won't break legitimate work.

### Task B1: Gap 11 — plugin touches-files escape detector

**Files:**
- Modify: `claw_forge/spec/validator.py` (new check fn after `check_touches_files_resolve` ~line 1119; wire into `run_shape_checks` per-feature loop ~line 1203-1218)
- Test: `tests/spec/test_validator_shape.py` (new `TestPluginTouchesEscape` class)

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

Append to `tests/spec/test_validator_shape.py` (imports `FeatureItem` from `claw_forge.spec.parser` and `check_plugin_touches_escape`, `IssueSeverity` from `claw_forge.spec.validator` — match the existing import style at the top of that file):

```python
class TestPluginTouchesEscape:
    def _feat(self, touches):
        return FeatureItem(
            category="Auth",
            description="User can register",
            shape="plugin",
            plugin="auth",
            touches_files=touches,
        )

    def test_in_dir_touches_no_issue(self):
        from claw_forge.spec.validator import check_plugin_touches_escape
        feat = self._feat(["src/plugins/auth/**"])
        assert check_plugin_touches_escape(feat) is None

    def test_escape_to_shared_file_flags_warning(self):
        from claw_forge.spec.validator import (
            check_plugin_touches_escape, IssueSeverity,
        )
        feat = self._feat(["src/plugins/auth/**", "src/app/router.py"])
        issue = check_plugin_touches_escape(feat)
        assert issue is not None
        assert issue.severity == IssueSeverity.WARNING
        assert issue.layer == 4
        assert "router.py" in issue.message

    def test_non_plugin_shape_skipped(self):
        from claw_forge.spec.validator import check_plugin_touches_escape
        feat = self._feat(["src/app/router.py"])
        feat.shape = "core"
        assert check_plugin_touches_escape(feat) is None

    def test_plugin_without_name_skipped(self):
        from claw_forge.spec.validator import check_plugin_touches_escape
        feat = self._feat(["src/app/router.py"])
        feat.plugin = None
        assert check_plugin_touches_escape(feat) is None

    def test_empty_touches_skipped(self):
        from claw_forge.spec.validator import check_plugin_touches_escape
        assert check_plugin_touches_escape(self._feat([])) is None
```

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

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

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

In `claw_forge/spec/validator.py`, add after `check_touches_files_resolve` (~line 1119):

```python
def check_plugin_touches_escape(feat: FeatureItem) -> ValidationIssue | None:
    """Gap 11 — a shape='plugin' feature whose touches_files reach outside
    its own plugin directory edits a shared registration seam.

    Such an edit (a central router, entry-points table, app factory) is
    exactly where parallel plugin agents collide, and it is what the
    opt-in sandbox fence (agent.sandbox.fence_to_touches_files) forbids at
    runtime.  Always WARNING — promoting to ERROR would break every spec
    that still registers plugins via a shared file before auto-discovery
    is in place.  The fix is convention-based discovery so the feature's
    footprint stays inside its own directory.
    """
    if feat.shape != "plugin" or not feat.plugin:
        return None
    if not feat.touches_files:
        return None
    from claw_forge.spec.parser import DEFAULT_PLUGIN_ROOT

    own_prefix = f"{DEFAULT_PLUGIN_ROOT}/{feat.plugin}/"
    escapes = [
        tf.strip() for tf in feat.touches_files
        if tf.strip() and not tf.strip().startswith(own_prefix)
    ]
    if not escapes:
        return None
    escapes_str = ", ".join(escapes)
    return ValidationIssue(
        severity=IssueSeverity.WARNING,
        layer=4,
        category=feat.category,
        bullet=feat.description,
        message=(
            f"plugin-shape feature declares touches_files outside its own "
            f"directory {own_prefix}: {escapes_str}.  Editing a shared "
            f"registration file forces parallel agents to collide there."
        ),
        suggestion=(
            f"Register the plugin via auto-discovery (drop a module under "
            f"{own_prefix} that the host loads by convention — no central "
            f"registry edit) so touches_files stays within {own_prefix}.  "
            f"If a shared edit is genuinely unavoidable, mark the feature "
            f'shape="core" so the scheduler serialises it.'
        ),
    )
```

- [ ] **Step 4: Wire into `run_shape_checks`**

In the per-feature loop of `run_shape_checks` (~line 1203-1218), after the `check_unmatched_phase_inference` block, add:

```python
        issue = check_plugin_touches_escape(feat)
        if issue is not None:
            issues.append(issue)
```

Also update the `run_shape_checks` docstring "Composes the eight check functions" list to "nine" and add the Gap 11 line.

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

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

- [ ] **Step 6: Run the full validator suite for regressions**

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

- [ ] **Step 7: Lint + commit**

```bash
uv run ruff check claw_forge/spec/validator.py tests/spec/test_validator_shape.py
git add claw_forge/spec/validator.py tests/spec/test_validator_shape.py
git commit -m "$(cat <<'EOF'
feat(validator): Gap 11 — flag plugin touches_files escaping its own dir

A shape="plugin" feature reaching into a shared registration file is
where parallel agents collide and what the Phase C sandbox fence forbids.
Always WARNING; points to convention-based auto-discovery.

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

### Task B2: Auto-discovery directive for plugin-shape agents

**Files:**
- Modify: `claw_forge/orchestrator/task_handler.py` (near the existing "Workspace boundary" system-prompt append; confirm exact location at execution time via `grep -n "Workspace boundary" claw_forge/`)
- Test: `tests/orchestrator/` (extend the nearest existing system-prompt test, or add a focused unit test on the directive builder)

- [ ] **Step 1: Locate the system-prompt boundary directive**

Run: `grep -rn "Workspace boundary\|## Workspace" claw_forge/`
Expected: a single append site (per CLAUDE.md, the boundary paragraph is appended to the agent system prompt). Note the file:line and the variable holding the prompt.

- [ ] **Step 2: Write the failing test**

Add a test asserting that when a task has `shape == "plugin"`, the assembled system prompt contains the auto-discovery directive. Use the existing test harness for prompt assembly in `tests/orchestrator/` (mirror the closest existing "prompt contains X" test). The assertion:

```python
def test_plugin_shape_prompt_has_autodiscovery_directive(self):
    prompt = build_system_prompt_for_task(shape="plugin", plugin="auth", ...)
    assert "register via auto-discovery" in prompt.lower()
    assert "do not edit any central registry" in prompt.lower()
```

(Adapt `build_system_prompt_for_task` to the real assembly function found in Step 1.)

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

Run: `uv run pytest tests/orchestrator/ -k autodiscovery -v`
Expected: FAIL (directive absent)

- [ ] **Step 4: Append the directive for plugin-shape tasks**

At the boundary-directive append site, when the task's shape is `"plugin"`, append:

```python
PLUGIN_AUTODISCOVERY_DIRECTIVE = (
    "\n\n## Plugin registration (CRITICAL)\n"
    "This is a self-contained plugin feature. Register it via "
    "auto-discovery — place your module inside this plugin's own "
    "directory so the host loads it by convention. Do NOT edit any "
    "central registry, router, entry-points table, or app factory to "
    "wire yourself in: those are shared files and editing them makes "
    "parallel agents collide. If you believe a shared edit is "
    "unavoidable, stop and surface it in HANDOFF.md rather than editing "
    "outside your directory."
)
```

Gate the append on `shape == "plugin"`.

- [ ] **Step 5: Run test to verify it passes**

Run: `uv run pytest tests/orchestrator/ -k autodiscovery -v`
Expected: PASS

- [ ] **Step 6: Lint + commit**

```bash
uv run ruff check claw_forge/orchestrator/task_handler.py
git add claw_forge/orchestrator/task_handler.py tests/orchestrator/
git commit -m "$(cat <<'EOF'
feat(agent): instruct plugin-shape agents to register via auto-discovery

Append a directive telling plugin-shape tasks to stay inside their own
directory and never edit a shared registry — removes the need to touch
shared files, the precondition for the Phase C sandbox fence.

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

---

## Phase C — Tighten the sandbox fence to the plugin dir (opt-in)

**Context for the engineer:** Today the OS sandbox allows writes to the entire worktree (`sandbox.py:276` macOS, `:409` Linux). For a `shape="plugin"` task we want to narrow the writable set to the plugin's own directory (derived from `touches_files`), plus the irreducible scratch dir (`<worktree>/.claw-forge/`) and the already-carved git common dir. This ships **opt-in** via `agent.sandbox.fence_to_touches_files` (default `false`) because it converts an out-of-footprint write into a hard `EACCES`. Phase B must already be landed so plugin tasks don't legitimately need shared-file writes.

### Task C1: `derive_plugin_writable_roots` helper

**Files:**
- Modify: `claw_forge/agent/sandbox.py` (new module-level helper near the other path helpers ~line 162)
- Test: `tests/agent/test_sandbox.py` (new `TestDeriveWritableRoots` class)

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

Append to `tests/agent/test_sandbox.py`:

```python
class TestDeriveWritableRoots:
    def test_dir_glob_narrows_to_dir(self, tmp_path):
        from claw_forge.agent.sandbox import derive_plugin_writable_roots
        roots = derive_plugin_writable_roots(
            tmp_path, ["src/plugins/auth/**"])
        assert roots == [(tmp_path / "src/plugins/auth").resolve()]

    def test_multiple_globs(self, tmp_path):
        from claw_forge.agent.sandbox import derive_plugin_writable_roots
        roots = derive_plugin_writable_roots(
            tmp_path, ["src/plugins/auth/**", "src/plugins/auth/sub/*.py"])
        assert (tmp_path / "src/plugins/auth").resolve() in roots

    def test_literal_file_uses_parent(self, tmp_path):
        from claw_forge.agent.sandbox import derive_plugin_writable_roots
        roots = derive_plugin_writable_roots(
            tmp_path, ["src/plugins/auth/handler.py"])
        assert (tmp_path / "src/plugins/auth").resolve() in roots

    def test_leading_glob_returns_none(self, tmp_path):
        """A glob with no fixed prefix can't be safely narrowed → None."""
        from claw_forge.agent.sandbox import derive_plugin_writable_roots
        assert derive_plugin_writable_roots(tmp_path, ["**/*.py"]) is None

    def test_empty_touches_returns_none(self, tmp_path):
        from claw_forge.agent.sandbox import derive_plugin_writable_roots
        assert derive_plugin_writable_roots(tmp_path, []) is None
```

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

Run: `uv run pytest tests/agent/test_sandbox.py::TestDeriveWritableRoots -v`
Expected: FAIL with `ImportError: cannot import name 'derive_plugin_writable_roots'`

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

In `claw_forge/agent/sandbox.py`, add:

```python
_GLOB_METACHARS = "*?["


def _glob_fixed_prefix_dir(entry: str) -> str | None:
    """Longest leading directory of *entry* containing no glob metachar.

    ``src/plugins/auth/**`` → ``src/plugins/auth``.
    ``src/plugins/auth/handler.py`` → ``src/plugins/auth`` (literal file →
    its parent).  ``**/*.py`` → None (no fixed prefix; can't narrow).
    """
    parts = entry.strip().split("/")
    fixed: list[str] = []
    saw_glob = False
    for p in parts:
        if any(c in p for c in _GLOB_METACHARS):
            saw_glob = True
            break
        fixed.append(p)
    if not fixed:
        return None
    # A literal file (no glob anywhere, last segment looks like a file)
    # narrows to its parent directory so siblings can be created.
    if not saw_glob and "." in fixed[-1]:
        fixed = fixed[:-1]
    if not fixed:
        return None
    return "/".join(fixed)


def derive_plugin_writable_roots(
    worktree_path: Path, touches_files: list[str],
) -> list[Path] | None:
    """Resolve a plugin feature's touches_files to writable directories.

    Returns the deduped, resolved directories the agent may write to, or
    ``None`` when narrowing isn't safe (no touches_files, or any entry has
    no fixed directory prefix) — the caller then falls back to the
    whole-worktree allow.
    """
    if not touches_files:
        return None
    roots: list[Path] = []
    seen: set[Path] = set()
    for entry in touches_files:
        prefix = _glob_fixed_prefix_dir(entry)
        if prefix is None:
            return None  # can't fence safely → caller uses whole worktree
        resolved = (worktree_path / prefix).resolve()
        if resolved not in seen:
            seen.add(resolved)
            roots.append(resolved)
    return roots
```

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

Run: `uv run pytest tests/agent/test_sandbox.py::TestDeriveWritableRoots -v`
Expected: PASS (all 5)

- [ ] **Step 5: Lint + commit**

```bash
uv run ruff check claw_forge/agent/sandbox.py tests/agent/test_sandbox.py
git add claw_forge/agent/sandbox.py tests/agent/test_sandbox.py
git commit -m "$(cat <<'EOF'
feat(sandbox): derive_plugin_writable_roots from touches_files

Maps a plugin feature's touches_files globs to the directories the agent
may write to; returns None when narrowing isn't safe (caller falls back
to the whole-worktree allow). Foundation for the opt-in fence.

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

### Task C2: Thread `writable_subpaths` through the profile builders

**Files:**
- Modify: `claw_forge/agent/sandbox.py` — `_macos_sandbox_profile` (~line 228), `_linux_bwrap_args` (~line 362), `write_macos_sandbox_wrapper` (~line 302), `write_linux_bwrap_wrapper` (~line 432), `make_sandbox_wrapper` (~line 484)
- Test: `tests/agent/test_sandbox.py` (new `TestWritableSubpathFence` class)

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

```python
class TestWritableSubpathFence:
    def test_macos_profile_narrows_to_subpaths(self, tmp_path):
        from claw_forge.agent.sandbox import _macos_sandbox_profile
        wt = (tmp_path / "wt").resolve()
        plugin_dir = (wt / "src/plugins/auth").resolve()
        prof = _macos_sandbox_profile(
            project_canonical=tmp_path.resolve(),
            worktree_canonical=wt,
            writable_subpaths=[plugin_dir],
            home_protection=False,
        )
        # plugin dir allowed; whole-worktree NOT a blanket allow
        assert f'(allow file-write* (subpath "{plugin_dir}"))' in prof
        assert f'(allow file-write* (subpath "{wt}"))' not in prof
        # scratch dir always allowed
        assert f'{wt}/.claw-forge' in prof

    def test_macos_profile_none_keeps_whole_worktree(self, tmp_path):
        from claw_forge.agent.sandbox import _macos_sandbox_profile
        wt = (tmp_path / "wt").resolve()
        prof = _macos_sandbox_profile(
            project_canonical=tmp_path.resolve(),
            worktree_canonical=wt,
            writable_subpaths=None,
            home_protection=False,
        )
        assert f'(allow file-write* (subpath "{wt}"))' in prof

    def test_linux_args_narrow_to_subpaths(self, tmp_path):
        from claw_forge.agent.sandbox import _linux_bwrap_args
        wt = (tmp_path / "wt").resolve()
        plugin_dir = (wt / "src/plugins/auth").resolve()
        args = _linux_bwrap_args(
            project_canonical=tmp_path.resolve(),
            worktree_canonical=wt,
            writable_subpaths=[plugin_dir],
            home_protection=False,
        )
        joined = " ".join(args)
        # worktree mounted read-only, plugin dir re-bound writable
        assert f"--ro-bind {wt} {wt}" in joined
        assert f"--bind {plugin_dir} {plugin_dir}" in joined

    def test_linux_args_none_keeps_whole_worktree(self, tmp_path):
        from claw_forge.agent.sandbox import _linux_bwrap_args
        wt = (tmp_path / "wt").resolve()
        args = _linux_bwrap_args(
            project_canonical=tmp_path.resolve(),
            worktree_canonical=wt,
            writable_subpaths=None,
            home_protection=False,
        )
        assert f"--bind {wt} {wt}" in " ".join(args)
```

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

Run: `uv run pytest tests/agent/test_sandbox.py::TestWritableSubpathFence -v`
Expected: FAIL with `TypeError: ... unexpected keyword argument 'writable_subpaths'`

- [ ] **Step 3: Add `writable_subpaths` to `_macos_sandbox_profile`**

Add `writable_subpaths: list[Path] | None = None` to the signature. Replace the worktree-allow line (line 276) logic:

```python
    lines = [
        "(version 1)",
        "(allow default)",
        f'(deny file-write* (subpath "{project_canonical}"))',
    ]
    if writable_subpaths is None:
        lines.append(f'(allow file-write* (subpath "{worktree_canonical}"))')
    else:
        for sub in writable_subpaths:
            lines.append(f'(allow file-write* (subpath "{sub}"))')
        # Scratch dir the agent always needs (HANDOFF.md, sandbox scripts,
        # CONFLICT_PROPOSAL.md) lives outside the plugin dir but inside the
        # worktree — always allow it when fencing.
        scratch = (worktree_canonical / ".claw-forge").resolve()
        lines.append(f'(allow file-write* (subpath "{scratch}"))')
```

(The `git_common_dir` and `home_protection` blocks below stay unchanged.)

- [ ] **Step 4: Add `writable_subpaths` to `_linux_bwrap_args`**

Add the same kwarg. Replace the worktree `--bind` (line 409):

```python
    args = [
        "--dev-bind", "/", "/",
        "--ro-bind", str(project_canonical), str(project_canonical),
    ]
    if writable_subpaths is None:
        args += ["--bind", str(worktree_canonical), str(worktree_canonical)]
    else:
        # Worktree read-only, then re-bind only the fenced subpaths +
        # scratch writable (later mounts override at overlapping subpaths).
        args += ["--ro-bind", str(worktree_canonical), str(worktree_canonical)]
        for sub in writable_subpaths:
            args += ["--bind", str(sub), str(sub)]
        scratch = (worktree_canonical / ".claw-forge").resolve()
        args += ["--bind", str(scratch), str(scratch)]
```

(The `git_common_dir`, `home_protection`, and `--chdir` blocks below stay unchanged.)

- [ ] **Step 5: Thread the kwarg through the wrappers + dispatcher**

Add `writable_subpaths: list[Path] | None = None` to `write_macos_sandbox_wrapper`, `write_linux_bwrap_wrapper`, and `make_sandbox_wrapper`, passing it down to the respective profile/args builder. In `make_sandbox_wrapper`, forward it to whichever OS wrapper it selects.

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

Run: `uv run pytest tests/agent/test_sandbox.py::TestWritableSubpathFence -v`
Expected: PASS (all 4)

- [ ] **Step 7: Run the full sandbox suite for regressions**

Run: `uv run pytest tests/agent/test_sandbox.py -q`
Expected: PASS (the `writable_subpaths=None` default preserves every existing test's whole-worktree behavior)

- [ ] **Step 8: Lint + commit**

```bash
uv run ruff check claw_forge/agent/sandbox.py tests/agent/test_sandbox.py
git add claw_forge/agent/sandbox.py tests/agent/test_sandbox.py
git commit -m "$(cat <<'EOF'
feat(sandbox): optional writable_subpaths fence in profile builders

When writable_subpaths is set, narrow the OS sandbox writable set to
those dirs + the .claw-forge scratch dir instead of the whole worktree.
Default None preserves current whole-worktree behavior.

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

### Task C3: Config flag + dispatcher wiring

**Files:**
- Modify: `claw_forge/config.py:247-248` (scaffold default), `claw_forge/orchestrator/run_cli.py:528-529` (read config), `claw_forge/orchestrator/task_handler.py:135` (RunContext field) + `:1255` (call site derivation)
- Test: `tests/orchestrator/test_run_extraction_smoke.py` (extend) + a config-read assertion

- [ ] **Step 1: Write the failing test**

Add a test asserting the call-site derivation: when `fence_to_touches_files` is True and the task is plugin-shape with dir-glob touches, `make_sandbox_wrapper` is called with non-None `writable_subpaths`; when the flag is False, it's None. Mirror the closest existing call-site test in `tests/orchestrator/`; if none exists, unit-test a small extracted helper `_resolve_fence_subpaths(ctx, task_node, worktree_path)` instead:

```python
def test_resolve_fence_subpaths_plugin_on(tmp_path):
    from claw_forge.orchestrator.task_handler import _resolve_fence_subpaths
    ctx = _ctx(fence_to_touches_files=True)
    node = TaskNode("a", "coding", 1, [], shape="plugin",
                    touches_files=["src/plugins/auth/**"])
    roots = _resolve_fence_subpaths(ctx, node, tmp_path)
    assert roots == [(tmp_path / "src/plugins/auth").resolve()]

def test_resolve_fence_subpaths_disabled(tmp_path):
    from claw_forge.orchestrator.task_handler import _resolve_fence_subpaths
    ctx = _ctx(fence_to_touches_files=False)
    node = TaskNode("a", "coding", 1, [], shape="plugin",
                    touches_files=["src/plugins/auth/**"])
    assert _resolve_fence_subpaths(ctx, node, tmp_path) is None

def test_resolve_fence_subpaths_core_unaffected(tmp_path):
    from claw_forge.orchestrator.task_handler import _resolve_fence_subpaths
    ctx = _ctx(fence_to_touches_files=True)
    node = TaskNode("a", "coding", 1, [], shape="core",
                    touches_files=["src/core/db.py"])
    assert _resolve_fence_subpaths(ctx, node, tmp_path) is None
```

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

Run: `uv run pytest tests/orchestrator/ -k resolve_fence -v`
Expected: FAIL (`_resolve_fence_subpaths` / `fence_to_touches_files` undefined)

- [ ] **Step 3: Add the RunContext field + helper**

In `task_handler.py`, add `sandbox_fence_to_touches_files: bool = False` to the `RunContext` dataclass (near `:135`). Add the helper:

```python
def _resolve_fence_subpaths(ctx, task_node, worktree_path):
    """Writable roots for the sandbox fence, or None to keep whole worktree.

    Only fences shape="plugin" tasks, and only when the flag is on.  Core /
    unannotated tasks and unsafe-to-narrow footprints keep the whole worktree.
    """
    if not ctx.sandbox_fence_to_touches_files:
        return None
    if getattr(task_node, "shape", None) != "plugin":
        return None
    from claw_forge.agent.sandbox import derive_plugin_writable_roots
    return derive_plugin_writable_roots(
        worktree_path, list(getattr(task_node, "touches_files", []) or []),
    )
```

- [ ] **Step 4: Use it at the `make_sandbox_wrapper` call site**

At `task_handler.py:1255`, pass:

```python
                            _isolation_cli_path = make_sandbox_wrapper(
                                project_path=ctx.project_path,
                                worktree_path=_worktree_path,
                                output_dir=(
                                    _worktree_path / ".claw-forge" / "sandbox"
                                ),
                                home_protection=ctx.sandbox_home_protection,
                                writable_subpaths=_resolve_fence_subpaths(
                                    ctx, task_node, _worktree_path,
                                ),
                            )
```

- [ ] **Step 5: Read the config in `run_cli.py`**

Near `:528-529` (where `sandbox_home_protection` is read), add:

```python
    sandbox_fence_to_touches_files: bool = bool(
        _sandbox_cfg.get("fence_to_touches_files", False),
    )
```

and pass `sandbox_fence_to_touches_files=sandbox_fence_to_touches_files` into the `RunContext` construction near `:1183`.

- [ ] **Step 6: Scaffold the default in `config.py`**

In `config.py`, under the `sandbox:` block (~line 247-248), add after `home_protection: true`:

```yaml
    # ``sandbox.fence_to_touches_files: false`` (opt-in).  When true, a
    #     shape="plugin" task's OS sandbox is narrowed from the whole
    #     worktree down to the directories its touches_files declare (plus
    #     the .claw-forge scratch dir).  An agent writing outside its
    #     declared footprint then fails with EACCES instead of silently
    #     editing a shared file.  Requires plugins to register via
    #     auto-discovery (validator Gap 11 flags shared-file edits).  Core
    #     and unannotated tasks are unaffected.  Default false until the
    #     false-positive rate is established.
    fence_to_touches_files: false
```

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

Run: `uv run pytest tests/orchestrator/ -k resolve_fence -v`
Expected: PASS

- [ ] **Step 8: Run the orchestrator + config suites for regressions**

Run: `uv run pytest tests/orchestrator/ tests/test_config_module.py -q`
Expected: PASS

- [ ] **Step 9: Lint + commit**

```bash
uv run ruff check claw_forge/config.py claw_forge/orchestrator/run_cli.py claw_forge/orchestrator/task_handler.py tests/orchestrator/
git add claw_forge/config.py claw_forge/orchestrator/run_cli.py claw_forge/orchestrator/task_handler.py tests/orchestrator/
git commit -m "$(cat <<'EOF'
feat(sandbox): opt-in agent.sandbox.fence_to_touches_files

Wire the per-plugin sandbox fence end to end: config flag (default off) →
RunContext → dispatcher derives writable roots from a plugin task's
touches_files and passes them to make_sandbox_wrapper.

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

---

## Phase D — Documentation (required before release)

**Context:** Per project convention, new config flags + behavior changes update CLAUDE.md, docs/commands.md, and README.md before release. Do NOT defer.

### Task D1: Update docs

**Files:**
- Modify: `CLAUDE.md` (scheduler section + validator Layer 4 gap list + sandbox section)
- Modify: `docs/commands.md` (validate-spec Gap 11 entry; run/config `fence_to_touches_files`)
- Modify: `README.md` (one line on the fence flag if spec/config is documented there)

- [ ] **Step 1: CLAUDE.md — scheduler**

In the "Shape-aware scheduling" bullet, replace the description of `shape="core"` single-flight with the boundary-aware behavior: core tasks now run in parallel when their `touches_files` are disjoint, and serialize only on footprint overlap (or footprintless core ↔ running core); file-claim locks remain the authoritative guard.

- [ ] **Step 2: CLAUDE.md — validator Layer 4**

Add a **Gap 11** entry to the Layer-4 gap list: "`shape="plugin"` feature whose `touches_files` reach outside its own plugin dir (a shared registration seam). Always WARNING; points to auto-discovery. Precondition for the opt-in sandbox fence."

- [ ] **Step 3: CLAUDE.md — sandbox section**

In the Worktree-Leakage Defenses / Layer 4 section, document `agent.sandbox.fence_to_touches_files` (opt-in, default false): narrows the writable root for `shape="plugin"` tasks from the whole worktree to the touches_files-derived dirs + `.claw-forge` scratch. Note the dependency on auto-discovery (Phase B) and that core/unannotated tasks are unaffected.

- [ ] **Step 4: docs/commands.md + README.md**

Add the `fence_to_touches_files` config key under the run/config reference and the Gap 11 entry under `validate-spec`. Add one README sentence if config flags are listed there.

- [ ] **Step 5: Commit**

```bash
git add CLAUDE.md docs/commands.md README.md
git commit -m "$(cat <<'EOF'
docs: boundary-aware core, Gap 11, and the touches_files sandbox fence

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

---

## Final verification

- [ ] **Full suite + coverage gate**

Run: `uv run pytest tests/ -q --cov=claw_forge --cov-report=term-missing`
Expected: PASS, coverage ≥ 90%. Add targeted tests for any new uncovered branch the report flags (e.g. the `_glob_fixed_prefix_dir` leading-glob path, the `_resolve_fence_subpaths` core-skip branch).

- [ ] **Lint both paths (CI parity)**

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

- [ ] **Type check**

Run: `uv run mypy claw_forge/ --ignore-missing-imports`
Expected: clean (or no new errors vs. baseline).

- [ ] **Integration smoke**: with `fence_to_touches_files: true` in a throwaway project config, run a single plugin-shape task end to end and confirm the agent can write inside its plugin dir and gets a clean structured failure (not a crash) when it attempts a write outside it. macOS-only check that the SDK `SandboxSettings.enabled` stays False (nested-sandbox landmine).

---

## Self-Review

**Spec coverage:**
- Ask 2 (boundary-aware core) → Phase A ✅
- Ask 3 (auto-discovery so shared files aren't touched) → Phase B: Gap 11 detector (B1) + agent directive (B2) ✅
- Ask 1 (fence to plugin dir) → Phase C: helper (C1) + profile builders (C2) + config/dispatcher wiring (C3) ✅
- Ordering constraint (B before C) → enforced by phase order + opt-in default ✅
- Docs (project requirement) → Phase D ✅
- Migration-class core still serialized → covered by overlap-on-shared-glob (A1 `test_overlapping_core_tasks_serialize`) ✅

**Type/name consistency:**
- `derive_plugin_writable_roots(worktree_path, touches_files) -> list[Path] | None` used identically in C1, C3, and `_resolve_fence_subpaths`.
- `writable_subpaths` kwarg name consistent across `_macos_sandbox_profile`, `_linux_bwrap_args`, both wrappers, and `make_sandbox_wrapper`.
- `check_plugin_touches_escape(feat)` signature matches its `run_shape_checks` call site.
- `sandbox_fence_to_touches_files` (RunContext) ↔ `fence_to_touches_files` (yaml key) — intentionally different (field vs. config key), mapped in C3 Step 5.

**Known soft spots (flagged, not placeholders):**
- B2's exact prompt-append location is confirmed via grep at execution (Step 1) because the boundary directive site wasn't read during planning; the directive text itself is concrete.
- C3's test shape (`_resolve_fence_subpaths` helper vs. mocking the call site) depends on existing `tests/orchestrator/` harness conventions; the helper extraction is the fallback and is itself fully specified.
