# Smart Untracked Handler 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:** At `claw-forge run` startup, scan the main worktree for untracked files and dispatch per-file actions automatically (archive with provenance / abort with clear error).  Hard-gate worktree creation: no agent dispatch until the main worktree is clean.

**Architecture:** A new pure-logic decision function (`decide_untracked_action`) classifies each untracked file by cross-referencing its path against the session's task `touches_files` declarations and its filesystem mtime.  Three actions: `archive` (move to `.claw-forge/orphans/<startup-ts>/` with `PROVENANCE.json`), `abort` (refuse the run, surface to user), `leave_alone` (build-output-like; log gitignore hint).  Composition function `smart_handle_untracked_in_project_dir()` walks the working tree, calls the decision function per file, executes the dispatched action, and returns a structured outcome.  Wired into `claw-forge run` startup right after `_ensure_state_service`, gated by a new `git.handle_untracked_at_startup` config (`smart`/`abort`/`ignore`, default `ignore` initially for one-version migration).

**Tech Stack:** Python 3.11, asyncio (consistent with rest of cli), pathlib, json (for provenance), subprocess (for git status). No new dependencies.

---

## File Structure

| Path | Action | Responsibility |
|---|---|---|
| `claw_forge/git/cleanup.py` | Modify | Add `Action` literal extension, `UntrackedDecision` dataclass, `UntrackedHandlingResult` dataclass, `decide_untracked_action()`, `smart_handle_untracked_in_project_dir()`, helper `_archive_one()` |
| `claw_forge/cli.py` | Modify | Read `git.handle_untracked_at_startup` config; call new function during `run` startup; render outcome to console; abort run if any cell returned `abort` |
| `claw_forge/cli.py:_DEFAULT_CONFIG_YAML` | Modify | Add commented `handle_untracked_at_startup: ignore` to scaffold |
| `tests/git/test_cleanup.py` | Modify | Add `TestDecideUntrackedAction` (pure logic, ~12 cases) and `TestSmartHandleUntrackedInProjectDir` (end-to-end, ~6 cases) |
| `tests/test_cli_run_flags.py` | Modify | Add `TestHandleUntrackedAtStartup` (3 cases — ignore/smart/abort modes propagate to handler) |
| `docs/commands.md` | Modify | New subsection under `claw-forge run`; new row in the run flags table for `--handle-untracked` if we expose CLI override (optional, see Task 5) |
| `CLAUDE.md` | Modify | New paragraph under "Git Worktree Lifecycle" for the startup untracked handler |

---

## Task 1: `decide_untracked_action` — pure decision logic

**Files:**
- Modify: `claw_forge/git/cleanup.py:49` (extend `Action` Literal); add new module-level constants and a new function below the existing `decide_action`
- Test: `tests/git/test_cleanup.py` (new test class `TestDecideUntrackedAction`)

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

Append to `tests/git/test_cleanup.py`:

```python
class TestDecideUntrackedAction:
    """Pure decision logic for an untracked file in the main worktree.

    No I/O — inputs are file path, mtime-age-seconds, and the
    set-of-known-touches-globs derived from the session's task list."""

    def test_path_matches_known_task_touches_globs_archives(self) -> None:
        from claw_forge.git.cleanup import decide_untracked_action

        decision = decide_untracked_action(
            path="app/tickets/service.py",
            mtime_age_seconds=86400 * 7,  # 7 days old
            known_touches_globs={"app/tickets/**"},
            recent_threshold_seconds=2 * 3600,
        )
        assert decision.action == "archive"
        assert decision.matched_glob == "app/tickets/**"

    def test_unknown_path_recent_aborts(self) -> None:
        from claw_forge.git.cleanup import decide_untracked_action

        decision = decide_untracked_action(
            path="app/scratch.py",
            mtime_age_seconds=300,  # 5 min old
            known_touches_globs=set(),
            recent_threshold_seconds=2 * 3600,
        )
        assert decision.action == "abort"
        assert "recent" in decision.reason.lower()

    def test_unknown_path_old_archives(self) -> None:
        from claw_forge.git.cleanup import decide_untracked_action

        decision = decide_untracked_action(
            path="app/orphan.py",
            mtime_age_seconds=86400 * 3,  # 3 days old
            known_touches_globs=set(),
            recent_threshold_seconds=2 * 3600,
        )
        assert decision.action == "archive"
        assert decision.matched_glob is None

    def test_path_matches_glob_even_when_recent(self) -> None:
        """Provenance trumps recency — known-task files always archive."""
        from claw_forge.git.cleanup import decide_untracked_action

        decision = decide_untracked_action(
            path="app/tickets/service.py",
            mtime_age_seconds=120,  # 2 min old
            known_touches_globs={"app/tickets/**"},
            recent_threshold_seconds=2 * 3600,
        )
        assert decision.action == "archive"

    def test_path_at_threshold_boundary(self) -> None:
        """mtime_age == threshold → treated as old (archives)."""
        from claw_forge.git.cleanup import decide_untracked_action

        decision = decide_untracked_action(
            path="app/scratch.py",
            mtime_age_seconds=2 * 3600,  # exactly at threshold
            known_touches_globs=set(),
            recent_threshold_seconds=2 * 3600,
        )
        assert decision.action == "archive"

    def test_glob_with_double_star_matches_nested(self) -> None:
        from claw_forge.git.cleanup import decide_untracked_action

        decision = decide_untracked_action(
            path="src/plugins/auth/handlers/login.py",
            mtime_age_seconds=300,
            known_touches_globs={"src/plugins/auth/**"},
            recent_threshold_seconds=2 * 3600,
        )
        assert decision.action == "archive"
        assert decision.matched_glob == "src/plugins/auth/**"

    def test_glob_with_single_star_does_not_match_nested(self) -> None:
        from claw_forge.git.cleanup import decide_untracked_action

        decision = decide_untracked_action(
            path="src/plugins/auth/handlers/login.py",
            mtime_age_seconds=300,
            known_touches_globs={"src/plugins/auth/*"},
            recent_threshold_seconds=2 * 3600,
        )
        # No glob match → falls through to recency check
        assert decision.action == "abort"

    def test_decision_carries_path_verbatim(self) -> None:
        from claw_forge.git.cleanup import decide_untracked_action

        decision = decide_untracked_action(
            path="app/x.py", mtime_age_seconds=300,
            known_touches_globs=set(), recent_threshold_seconds=2 * 3600,
        )
        assert decision.path == "app/x.py"

    def test_archive_action_carries_reason(self) -> None:
        from claw_forge.git.cleanup import decide_untracked_action

        decision = decide_untracked_action(
            path="app/old.py",
            mtime_age_seconds=86400 * 5,
            known_touches_globs=set(),
            recent_threshold_seconds=2 * 3600,
        )
        assert decision.action == "archive"
        assert decision.reason  # non-empty
```

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

Run: `uv run pytest tests/git/test_cleanup.py::TestDecideUntrackedAction -v`

Expected: 9 FAILs — `cannot import name 'decide_untracked_action' from 'claw_forge.git.cleanup'`.

- [ ] **Step 3: Add the dataclass + decision function**

In `claw_forge/git/cleanup.py`, add after the existing `decide_action` function (around line 96):

```python
# ── Untracked-file handling (startup hook) ─────────────────────────────────────
#
# At ``claw-forge run`` startup, the harness scans the main worktree for
# untracked files and dispatches per-file actions automatically.  Goal: no
# agent worktrees are created until the main worktree is clean.
#
# Decision matrix:
# - file path matches a known task's touches_files glob → archive (debris
#   from a previous run of that task)
# - no glob match, recent mtime → abort (looks like user WIP)
# - no glob match, old mtime → archive (cold orphan, likely from a
#   crashed agent that never committed)
#
# The recency cutoff (``recent_threshold_seconds``) is configurable via
# ``git.recent_untracked_threshold_hours`` in claw-forge.yaml.

UntrackedAction = Literal["archive", "abort", "leave_alone"]


@dataclass
class UntrackedDecision:
    """A single per-file decision from ``decide_untracked_action``."""

    path: str
    action: UntrackedAction
    reason: str
    matched_glob: str | None = None


def _glob_matches(path: str, pattern: str) -> bool:
    """Match *path* against *pattern* with ``**`` semantics.

    Matches the same globbing rules as Python's ``pathlib.PurePath.match``
    extended with ``**`` for "any number of path segments" — exactly what
    ``touches_files`` declarations use elsewhere in claw-forge.
    """
    from pathlib import PurePath

    if "**" not in pattern:
        return PurePath(path).match(pattern)
    # ``**`` — split into prefix and convert to a regex so we can match
    # zero-or-more path segments rather than fnmatch's literal ``*`` rule.
    import re

    parts = pattern.split("**")
    regex = ".*".join(re.escape(p).replace(r"\*", "[^/]*") for p in parts)
    return re.fullmatch(regex, path) is not None


def decide_untracked_action(
    *,
    path: str,
    mtime_age_seconds: float,
    known_touches_globs: set[str],
    recent_threshold_seconds: float,
) -> UntrackedDecision:
    """Decide what to do with one untracked file.

    Pure function — no I/O.  Inputs are the file's relative path, its
    age (``time.time() - file.stat().st_mtime``), the union of all
    ``touches_files`` globs across the session's tasks (any status),
    and the recency threshold.

    Returns an :class:`UntrackedDecision`.
    """
    for glob in known_touches_globs:
        if _glob_matches(path, glob):
            return UntrackedDecision(
                path=path,
                action="archive",
                reason=f"matches known task touches_files glob: {glob}",
                matched_glob=glob,
            )
    if mtime_age_seconds < recent_threshold_seconds:
        return UntrackedDecision(
            path=path,
            action="abort",
            reason=(
                f"recent file (modified < "
                f"{recent_threshold_seconds / 3600:.1f}h ago) "
                f"with no task provenance — looks like user WIP"
            ),
        )
    return UntrackedDecision(
        path=path,
        action="archive",
        reason="old orphan with no task provenance",
    )
```

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

Run: `uv run pytest tests/git/test_cleanup.py::TestDecideUntrackedAction -v`

Expected: 9 PASS.

- [ ] **Step 5: Commit**

```bash
git add claw_forge/git/cleanup.py tests/git/test_cleanup.py
git commit -m "feat(git): decide_untracked_action — pure decision logic for untracked files"
```

---

## Task 2: `_archive_one` and `_collect_untracked_files` helpers

**Files:**
- Modify: `claw_forge/git/cleanup.py` (add helpers below `decide_untracked_action`)
- Test: `tests/git/test_cleanup.py` (add to a new helper test class)

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

Append to `tests/git/test_cleanup.py`:

```python
class TestArchiveOne:
    """``_archive_one`` moves one untracked file into a timestamp dir
    under .claw-forge/orphans/, writing PROVENANCE.json next to it."""

    def test_moves_file_and_writes_provenance(
        self, git_repo: Path, tmp_path: Path,
    ) -> None:
        from claw_forge.git.cleanup import UntrackedDecision, _archive_one

        # Create an untracked file in the project dir
        scratch = git_repo / "app" / "scratch.py"
        scratch.parent.mkdir(parents=True)
        scratch.write_text("# scratch\n")

        archive_root = git_repo / ".claw-forge" / "orphans" / "20260509-130000"
        archive_root.mkdir(parents=True)

        decision = UntrackedDecision(
            path="app/scratch.py",
            action="archive",
            reason="old orphan",
            matched_glob=None,
        )
        archived_path = _archive_one(
            project_dir=git_repo,
            archive_root=archive_root,
            decision=decision,
        )

        # Original gone, archived present
        assert not scratch.exists()
        assert archived_path.exists()
        assert archived_path.read_text() == "# scratch\n"
        assert archived_path == archive_root / "app" / "scratch.py"

        # Provenance file written
        provenance = archive_root / "app" / "scratch.py.PROVENANCE.json"
        assert provenance.exists()
        import json
        data = json.loads(provenance.read_text())
        assert data["path"] == "app/scratch.py"
        assert data["reason"] == "old orphan"

    def test_preserves_directory_structure(
        self, git_repo: Path,
    ) -> None:
        from claw_forge.git.cleanup import UntrackedDecision, _archive_one

        deep = git_repo / "a" / "b" / "c" / "deep.py"
        deep.parent.mkdir(parents=True)
        deep.write_text("x")

        archive_root = git_repo / ".claw-forge" / "orphans" / "ts"
        archive_root.mkdir(parents=True)

        decision = UntrackedDecision(
            path="a/b/c/deep.py", action="archive",
            reason="r", matched_glob=None,
        )
        archived = _archive_one(
            project_dir=git_repo, archive_root=archive_root, decision=decision,
        )
        assert archived == archive_root / "a" / "b" / "c" / "deep.py"
        assert archived.read_text() == "x"


class TestCollectUntrackedFiles:
    """Read ``git status --porcelain --untracked-files=normal`` and parse
    the ``?? <path>`` lines into a list of relative paths."""

    def test_no_untracked_returns_empty(self, git_repo: Path) -> None:
        from claw_forge.git.cleanup import _collect_untracked_files

        assert _collect_untracked_files(git_repo) == []

    def test_lists_untracked_files(self, git_repo: Path) -> None:
        from claw_forge.git.cleanup import _collect_untracked_files

        (git_repo / "a.py").write_text("x")
        (git_repo / "sub").mkdir()
        (git_repo / "sub" / "b.py").write_text("y")

        result = _collect_untracked_files(git_repo)
        assert "a.py" in result
        assert "sub/b.py" in result

    def test_ignores_tracked_files(self, git_repo: Path) -> None:
        from claw_forge.git.cleanup import _collect_untracked_files

        # README.md is tracked from the fixture
        result = _collect_untracked_files(git_repo)
        assert "README.md" not in result

    def test_ignores_gitignored_paths(self, git_repo: Path) -> None:
        from claw_forge.git.cleanup import _collect_untracked_files

        (git_repo / ".gitignore").write_text("ignored/\n")
        subprocess.run(
            ["git", "add", ".gitignore"], cwd=git_repo, check=True,
        )
        subprocess.run(
            ["git", "commit", "-q", "-m", "ignore"], cwd=git_repo, check=True,
        )
        (git_repo / "ignored").mkdir()
        (git_repo / "ignored" / "x.py").write_text("x")
        (git_repo / "tracked-untracked.py").write_text("y")

        result = _collect_untracked_files(git_repo)
        assert "ignored/x.py" not in result
        assert "tracked-untracked.py" in result
```

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

Run: `uv run pytest tests/git/test_cleanup.py::TestArchiveOne tests/git/test_cleanup.py::TestCollectUntrackedFiles -v`

Expected: FAILs with `cannot import name '_archive_one'` / `'_collect_untracked_files'`.

- [ ] **Step 3: Add the helpers**

In `claw_forge/git/cleanup.py`, append after `decide_untracked_action`:

```python
def _collect_untracked_files(project_dir: Path) -> list[str]:
    """Return the list of untracked files in *project_dir* as relative paths.

    Honors ``.gitignore`` (uses ``git status --porcelain --untracked-files=normal``).
    Returns an empty list if the directory isn't a git repo.
    """
    try:
        result = _run_git(
            ["status", "--porcelain", "--untracked-files=normal"], project_dir,
        )
    except Exception:  # noqa: BLE001
        return []
    out: list[str] = []
    for line in result.stdout.splitlines():
        # ``?? <path>`` for untracked files; strip the prefix and any
        # surrounding whitespace.  Quoted paths (with embedded special
        # chars) come back in literal form here — git's --porcelain
        # contract guarantees the path follows the two-char status code
        # plus a single space.
        if line.startswith("?? "):
            out.append(line[3:])
    return out


def _archive_one(
    *,
    project_dir: Path,
    archive_root: Path,
    decision: UntrackedDecision,
) -> Path:
    """Move one untracked file from *project_dir* to *archive_root*,
    preserving the relative directory structure.  Writes a sibling
    ``<filename>.PROVENANCE.json`` describing the decision.

    Returns the destination path.
    """
    import json
    import shutil as _shutil

    src = project_dir / decision.path
    dst = archive_root / decision.path
    dst.parent.mkdir(parents=True, exist_ok=True)
    _shutil.move(str(src), str(dst))

    provenance_path = dst.with_name(dst.name + ".PROVENANCE.json")
    provenance_path.write_text(
        json.dumps(
            {
                "path": decision.path,
                "action": decision.action,
                "reason": decision.reason,
                "matched_glob": decision.matched_glob,
            },
            indent=2,
        )
        + "\n"
    )
    return dst
```

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

Run: `uv run pytest tests/git/test_cleanup.py::TestArchiveOne tests/git/test_cleanup.py::TestCollectUntrackedFiles -v`

Expected: 5 PASS.

- [ ] **Step 5: Commit**

```bash
git add claw_forge/git/cleanup.py tests/git/test_cleanup.py
git commit -m "feat(git): _collect_untracked_files and _archive_one helpers"
```

---

## Task 3: `smart_handle_untracked_in_project_dir` composition

**Files:**
- Modify: `claw_forge/git/cleanup.py` (add `UntrackedHandlingResult` dataclass + main entrypoint)
- Test: `tests/git/test_cleanup.py` (new `TestSmartHandleUntrackedInProjectDir` class)

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

Append to `tests/git/test_cleanup.py`:

```python
class TestSmartHandleUntrackedInProjectDir:
    """Top-level handler.  Returns an UntrackedHandlingResult; callers
    abort the run when ``result.proceed is False``."""

    def test_no_untracked_proceeds(self, git_repo: Path) -> None:
        from claw_forge.git.cleanup import smart_handle_untracked_in_project_dir

        result = smart_handle_untracked_in_project_dir(
            project_dir=git_repo, tasks=[], recent_threshold_seconds=7200,
        )
        assert result.proceed is True
        assert result.archived == []
        assert result.aborts == []

    def test_known_path_archived(self, git_repo: Path) -> None:
        from claw_forge.git.cleanup import smart_handle_untracked_in_project_dir

        # Create an old untracked file (force mtime backward)
        f = git_repo / "app" / "service.py"
        f.parent.mkdir()
        f.write_text("x")
        # No need to touch mtime — known-glob path archives regardless of age

        tasks = [{"touches_files": ["app/**"]}]
        result = smart_handle_untracked_in_project_dir(
            project_dir=git_repo, tasks=tasks, recent_threshold_seconds=7200,
        )
        assert result.proceed is True
        assert "app/service.py" in result.archived
        assert not f.exists()

        # Archive dir created with timestamp
        archives = list(
            (git_repo / ".claw-forge" / "orphans").iterdir()
        )
        assert len(archives) == 1
        assert (archives[0] / "app" / "service.py").exists()

    def test_recent_unknown_path_aborts(self, git_repo: Path) -> None:
        from claw_forge.git.cleanup import smart_handle_untracked_in_project_dir

        f = git_repo / "wip.py"
        f.write_text("y")
        # File mtime is "now" — well within the 2h threshold

        result = smart_handle_untracked_in_project_dir(
            project_dir=git_repo, tasks=[], recent_threshold_seconds=7200,
        )
        assert result.proceed is False
        assert "wip.py" in result.aborts
        assert f.exists()  # untouched on abort

    def test_old_unknown_path_archived(
        self, git_repo: Path,
    ) -> None:
        from claw_forge.git.cleanup import smart_handle_untracked_in_project_dir

        import os
        f = git_repo / "old.py"
        f.write_text("z")
        # Set mtime to 1 day ago
        os.utime(f, (f.stat().st_atime, f.stat().st_mtime - 86400))

        result = smart_handle_untracked_in_project_dir(
            project_dir=git_repo, tasks=[], recent_threshold_seconds=7200,
        )
        assert result.proceed is True
        assert "old.py" in result.archived
        assert not f.exists()

    def test_mixed_archive_and_abort(self, git_repo: Path) -> None:
        """One file archives, one aborts → result.proceed is False; the
        archivable one is still archived (no rollback)."""
        from claw_forge.git.cleanup import smart_handle_untracked_in_project_dir

        # Old + known
        archived_target = git_repo / "known.py"
        archived_target.write_text("a")
        import os
        os.utime(
            archived_target,
            (archived_target.stat().st_atime,
             archived_target.stat().st_mtime - 86400),
        )

        # Recent + unknown
        wip = git_repo / "wip.py"
        wip.write_text("b")

        result = smart_handle_untracked_in_project_dir(
            project_dir=git_repo, tasks=[],
            recent_threshold_seconds=7200,
        )
        assert result.proceed is False
        assert "known.py" in result.archived
        assert "wip.py" in result.aborts
        assert not archived_target.exists()
        assert wip.exists()

    def test_returns_archive_root_path(self, git_repo: Path) -> None:
        """Caller can render the archive path so user can recover."""
        from claw_forge.git.cleanup import smart_handle_untracked_in_project_dir

        f = git_repo / "old.py"
        f.write_text("z")
        import os
        os.utime(f, (f.stat().st_atime, f.stat().st_mtime - 86400))

        result = smart_handle_untracked_in_project_dir(
            project_dir=git_repo, tasks=[], recent_threshold_seconds=7200,
        )
        assert result.archive_root is not None
        assert result.archive_root.is_dir()
        assert (result.archive_root / "old.py").exists()
```

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

Run: `uv run pytest tests/git/test_cleanup.py::TestSmartHandleUntrackedInProjectDir -v`

Expected: FAILs with `cannot import name 'smart_handle_untracked_in_project_dir'`.

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

In `claw_forge/git/cleanup.py`, append:

```python
@dataclass
class UntrackedHandlingResult:
    """Outcome of ``smart_handle_untracked_in_project_dir``.

    ``proceed`` is True iff every untracked file dispatched to a non-abort
    action (archive or leave_alone).  Callers MUST abort the agent run when
    ``proceed is False`` — there is no rollback path past this point.
    """

    decisions: list[UntrackedDecision] = field(default_factory=list)
    archived: list[str] = field(default_factory=list)
    aborts: list[str] = field(default_factory=list)
    archive_root: Path | None = None

    @property
    def proceed(self) -> bool:
        return not self.aborts


def smart_handle_untracked_in_project_dir(
    *,
    project_dir: Path,
    tasks: list[dict[str, Any]],
    recent_threshold_seconds: float = 2 * 3600,
) -> UntrackedHandlingResult:
    """Scan *project_dir* for untracked files, decide per-file actions,
    and execute archive moves.

    Aborts (recent + no provenance) are detected but NOT acted on — the
    files are left where they are so the user can ``git add`` or stash
    them.  Caller surfaces ``result.aborts`` and refuses to dispatch.

    *tasks* is the session's task list (any ``status``).  Each task's
    ``touches_files`` field (a list of glob strings) becomes part of the
    known-provenance set used to classify files.
    """
    import time
    from datetime import datetime

    untracked = _collect_untracked_files(project_dir)
    if not untracked:
        return UntrackedHandlingResult()

    # Build the union of all touches_files globs across all tasks.
    known_globs: set[str] = set()
    for t in tasks:
        for g in t.get("touches_files") or []:
            known_globs.add(g)

    # Stable timestamp for the archive directory; only created on first
    # archive action so we don't litter empty dirs on a clean run.
    archive_root: Path | None = None

    result = UntrackedHandlingResult()
    now = time.time()
    for path in untracked:
        full = project_dir / path
        try:
            mtime_age = now - full.stat().st_mtime
        except OSError:
            mtime_age = 0.0
        decision = decide_untracked_action(
            path=path,
            mtime_age_seconds=mtime_age,
            known_touches_globs=known_globs,
            recent_threshold_seconds=recent_threshold_seconds,
        )
        result.decisions.append(decision)
        if decision.action == "abort":
            result.aborts.append(path)
            continue
        if decision.action == "archive":
            if archive_root is None:
                ts = datetime.now().strftime("%Y%m%d-%H%M%S")
                archive_root = project_dir / ".claw-forge" / "orphans" / ts
                archive_root.mkdir(parents=True, exist_ok=True)
                result.archive_root = archive_root
            _archive_one(
                project_dir=project_dir,
                archive_root=archive_root,
                decision=decision,
            )
            result.archived.append(path)
            continue
        # leave_alone — reserved for a future cell; no-op for now
    return result
```

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

Run: `uv run pytest tests/git/test_cleanup.py::TestSmartHandleUntrackedInProjectDir -v`

Expected: 6 PASS.

- [ ] **Step 5: Run the full git test suite**

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

Expected: all PASS, no regressions in existing cleanup tests.

- [ ] **Step 6: Commit**

```bash
git add claw_forge/git/cleanup.py tests/git/test_cleanup.py
git commit -m "feat(git): smart_handle_untracked_in_project_dir startup hook"
```

---

## Task 4: CLI wiring — read config, call handler, abort on conflict

**Files:**
- Modify: `claw_forge/cli.py` around lines 932 (where `git_cleanup_mode` is read) and 1031 (where `smart_cleanup_worktrees` is called) — add a parallel block for the untracked handler
- Test: `tests/test_cli_run_flags.py` (new `TestHandleUntrackedAtStartup` class)

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

Append to `tests/test_cli_run_flags.py`:

```python
class TestHandleUntrackedAtStartup:
    """Wiring: ``git.handle_untracked_at_startup`` config controls whether
    the new startup handler runs."""

    def test_ignore_mode_skips_handler(self, project: Path) -> None:
        """Default ``ignore`` mode preserves legacy behaviour."""
        from unittest.mock import patch

        cfg = _yaml(project)  # no git.handle_untracked_at_startup key
        # Create an untracked file the handler would otherwise abort on
        (project / "wip.py").write_text("x")

        with patch(
            "claw_forge.cli.smart_handle_untracked_in_project_dir"
        ) as mock_handler:
            captured, output, code = _invoke_run(cfg, project)

        # In ``ignore`` mode the handler should NOT be called at all
        assert mock_handler.call_count == 0
        assert code == 0
        # File is untouched
        assert (project / "wip.py").exists()

    def test_smart_mode_calls_handler(self, project: Path) -> None:
        from unittest.mock import patch

        cfg = _yaml(project, handle_untracked="smart")

        with patch(
            "claw_forge.cli.smart_handle_untracked_in_project_dir"
        ) as mock_handler:
            from claw_forge.git.cleanup import UntrackedHandlingResult
            mock_handler.return_value = UntrackedHandlingResult()
            captured, output, code = _invoke_run(cfg, project)

        assert mock_handler.call_count == 1
        assert code == 0

    def test_abort_mode_fails_when_untracked_exists(
        self, project: Path,
    ) -> None:
        cfg = _yaml(project, handle_untracked="abort")
        (project / "wip.py").write_text("x")

        captured, output, code = _invoke_run(cfg, project)

        assert code != 0
        assert "wip.py" in output

    def test_smart_mode_aborts_run_on_handler_abort(
        self, project: Path,
    ) -> None:
        from unittest.mock import patch
        from claw_forge.git.cleanup import (
            UntrackedDecision, UntrackedHandlingResult,
        )

        cfg = _yaml(project, handle_untracked="smart")

        with patch(
            "claw_forge.cli.smart_handle_untracked_in_project_dir"
        ) as mock_handler:
            mock_handler.return_value = UntrackedHandlingResult(
                aborts=["wip.py"],
                decisions=[UntrackedDecision(
                    path="wip.py", action="abort",
                    reason="recent + no provenance",
                )],
            )
            captured, output, code = _invoke_run(cfg, project)

        assert code != 0
        assert "wip.py" in output
```

Update `_yaml` helper in the same file to accept the new key:

```python
def _yaml(
    tmp_path: Path,
    *,
    push_after_merge: str | None = None,
    handle_untracked: str | None = None,
) -> Path:
    cfg = tmp_path / "claw-forge.yaml"
    body = (
        "providers: {}\n"
        "git:\n"
        "  enabled: true\n"
        "  merge_strategy: auto\n"
        "  branch_prefix: feat\n"
    )
    if push_after_merge is not None:
        body += f"  push_after_merge: {push_after_merge}\n"
    if handle_untracked is not None:
        body += f"  handle_untracked_at_startup: {handle_untracked}\n"
    body += (
        "agent:\n"
        "  default_model: claude-sonnet-4-6\n"
    )
    cfg.write_text(body)
    return cfg
```

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

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

Expected: FAILs (`smart_handle_untracked_in_project_dir` not yet imported in cli.py; `handle_untracked_at_startup` config not read).

- [ ] **Step 3: Wire into `cli.py` config-resolution**

In `claw_forge/cli.py` around line 936 (right after `git_llm_proposals`), add:

```python
    git_handle_untracked: str = git_cfg.get(
        "handle_untracked_at_startup", "ignore",
    )
    git_recent_untracked_hours: float = float(
        git_cfg.get("recent_untracked_threshold_hours", 2.0),
    )
```

- [ ] **Step 4: Wire the handler call into `cli.py` startup**

Find the spot in `cli.py` around line 1031 where `smart_cleanup_worktrees` is called (during the `main()` async function, after `_ensure_state_service` and before the dispatcher).  Add the untracked handler call BEFORE the worktree cleanup (untracked files block both worktree creation and the merge, so handle them first):

Add this import near the existing `smart_cleanup_worktrees` import:

```python
from claw_forge.git.cleanup import (
    smart_cleanup_worktrees,
    smart_handle_untracked_in_project_dir,
)
```

Then in the startup block — right after `_ensure_state_service` returns and before any task dispatch — add:

```python
        # ── Untracked-file handling ───────────────────────────────────────
        # When ``git.handle_untracked_at_startup: smart``, scan the main
        # worktree for untracked files and dispatch per-file actions
        # (archive cold debris with provenance; abort the run if any file
        # looks like recent user WIP).  Default is ``ignore`` for one-version
        # compatibility — flips to ``smart`` next minor.
        if git_handle_untracked == "abort":
            from claw_forge.git.cleanup import _collect_untracked_files
            _untracked = _collect_untracked_files(project_path)
            if _untracked:
                console.print(
                    f"[red]✗ {len(_untracked)} untracked file(s) in main worktree "
                    "— aborting (git.handle_untracked_at_startup=abort).[/red]"
                )
                for _p in _untracked[:20]:
                    console.print(f"  [dim]- {_p}[/dim]")
                console.print(
                    "[yellow]Resolve with `git add`, `git stash`, or `rm`, "
                    "then re-run.[/yellow]"
                )
                raise typer.Exit(1) from None
        elif git_handle_untracked == "smart":
            _utr = smart_handle_untracked_in_project_dir(
                project_dir=project_path,
                tasks=init_data.get("tasks") or [],
                recent_threshold_seconds=git_recent_untracked_hours * 3600,
            )
            if _utr.archived:
                console.print(
                    f"[yellow]Archived {len(_utr.archived)} untracked "
                    f"file(s) → {_utr.archive_root}[/yellow]"
                )
                for _p in _utr.archived[:10]:
                    console.print(f"  [dim]- {_p}[/dim]")
            if not _utr.proceed:
                console.print(
                    f"[red]✗ {len(_utr.aborts)} untracked file(s) in main "
                    "worktree look like user WIP — aborting.[/red]"
                )
                for _p in _utr.aborts[:20]:
                    console.print(f"  [dim]- {_p}[/dim]")
                console.print(
                    "[yellow]Resolve with `git add` or `git stash`, "
                    "then re-run.  Lower the recency threshold via "
                    "`git.recent_untracked_threshold_hours: 0` to archive "
                    "instead.[/yellow]"
                )
                raise typer.Exit(1) from None
```

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

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

Expected: 4 PASS.

- [ ] **Step 6: Run the full CLI flag test file to catch regressions**

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

Expected: all PASS.

- [ ] **Step 7: Commit**

```bash
git add claw_forge/cli.py tests/test_cli_run_flags.py
git commit -m "feat(cli): wire git.handle_untracked_at_startup into run startup"
```

---

## Task 5: Init scaffold — surface the new config key

**Files:**
- Modify: `claw_forge/cli.py:_DEFAULT_CONFIG_YAML` (the `git:` block)
- Test: `tests/test_plan_model.py` (extend existing scaffold-content tests)

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

Append to `tests/test_plan_model.py`:

```python
class TestDefaultConfigHasHandleUntracked:
    """Default config YAML surfaces git.handle_untracked_at_startup."""

    def test_key_present(self) -> None:
        from claw_forge.cli import _DEFAULT_CONFIG_YAML
        assert "handle_untracked_at_startup" in _DEFAULT_CONFIG_YAML

    def test_default_is_ignore(self) -> None:
        """First-version default is ``ignore`` (preserves current behaviour
        until users opt in).  Will flip to ``smart`` in the next minor."""
        import re
        import yaml as _yaml
        from claw_forge.cli import _DEFAULT_CONFIG_YAML

        cleaned = re.sub(r"\$\{[^}]+\}", "dummy", _DEFAULT_CONFIG_YAML)
        parsed = _yaml.safe_load(cleaned)
        assert "git" in parsed
        assert parsed["git"]["handle_untracked_at_startup"] == "ignore"
```

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

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

Expected: 2 FAILs.

- [ ] **Step 3: Add the key to the scaffold**

In `claw_forge/cli.py:_DEFAULT_CONFIG_YAML`, find the `push_after_merge: false` line and add immediately after it:

```yaml
  # At ``claw-forge run`` startup, scan the main worktree for untracked
  # files and dispatch per-file actions automatically.  ``smart`` matches
  # each file against task ``touches_files`` declarations + filesystem
  # mtime: archive cold debris (provenance recorded), abort the run if
  # any file looks like recent user WIP.  ``abort`` refuses to start
  # whenever any untracked file exists.  ``ignore`` is the legacy
  # behaviour — handler doesn't run.  Will flip to ``smart`` default
  # next minor.
  handle_untracked_at_startup: ignore   # ignore | smart | abort
  # Files newer than this with no task provenance trigger an abort
  # (treated as user WIP).  Lower this to 0 to archive everything
  # without provenance regardless of age.
  recent_untracked_threshold_hours: 2
```

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

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

Expected: 2 PASS.

- [ ] **Step 5: Commit**

```bash
git add claw_forge/cli.py tests/test_plan_model.py
git commit -m "feat(cli): surface git.handle_untracked_at_startup in init scaffold"
```

---

## Task 6: Documentation updates

**Files:**
- Modify: `CLAUDE.md` (under "Git Worktree Lifecycle" section)
- Modify: `docs/commands.md` (under `claw-forge run` config section)
- Modify: `README.md` (only if there's a config-keys reference; otherwise skip)

- [ ] **Step 1: CLAUDE.md — add a new bullet**

In `CLAUDE.md`, find the "Git Worktree Lifecycle" section's bullet list and append after the "Post-merge target push" bullet (the v0.8.6 entry):

```markdown
- **Startup untracked-file handling** (`git.handle_untracked_at_startup`, default `ignore`; will flip to `smart` next minor): at `claw-forge run` startup, scans the main worktree for untracked files and dispatches per-file actions automatically.  `smart` mode classifies each file by cross-referencing its path against the session's task `touches_files` declarations and its filesystem mtime — known-provenance and old-orphan files archive to `.claw-forge/orphans/<startup-ts>/<original-path>` with a sibling `PROVENANCE.json`; recent-mtime files with no provenance abort the run (preserves user WIP).  `abort` mode refuses to start whenever any untracked file exists (no auto-archive).  `ignore` mode is the legacy behaviour — handler doesn't run.  Hard-gate: no agent worktrees are created until the main worktree is clean.
```

- [ ] **Step 2: docs/commands.md — add a subsection**

In `docs/commands.md`, find the existing `### Push-After-Merge (--push-after-merge)` section and add a new section immediately after:

```markdown
### Untracked-File Handling at Startup (`git.handle_untracked_at_startup`)

**Why:** Untracked files in the main worktree silently block future squash-merges (when an agent's branch tracks the same path) and are an architectural debt accumulator.  This config moves the cleanup from "user notices the orphan archive eventually" to "harness handles it at run startup."

**Modes:**

| Mode | Behaviour |
|---|---|
| `ignore` (current default) | Handler doesn't run.  Legacy behaviour — untracked files persist and may cause future `_sideline_orphans` events. |
| `smart` | Per-file decision matrix.  Archives known-provenance debris with `PROVENANCE.json`.  Aborts the run if any file looks like recent user WIP. |
| `abort` | Refuses to start whenever any untracked file exists.  No auto-archive.  User must `git add`/`git stash`/`rm` before re-running. |

**Smart-mode decision matrix:**

| Signal | Action |
|---|---|
| Path matches a task's `touches_files` glob | Archive with `matched_glob` provenance |
| File mtime < `recent_untracked_threshold_hours` AND no provenance | Abort (looks like user WIP) |
| File mtime ≥ threshold AND no provenance | Archive with `provenance: unknown` |

**Configuration:**

```yaml
git:
  handle_untracked_at_startup: smart        # ignore | smart | abort
  recent_untracked_threshold_hours: 2       # files newer → abort; older → archive
```

**Reversibility:** every archived file lands at `.claw-forge/orphans/<startup-ts>/<original-path>` with a sibling `<filename>.PROVENANCE.json`.  Nothing is deleted.  Recover with `cp` from the archive.

**Migration:** default is `ignore` in this release for one-version compatibility; flips to `smart` next minor.  Opt in early to validate before the default change.
```

- [ ] **Step 3: Verify no other docs need updates**

Run: `grep -rn "handle_untracked\|untracked.*startup" docs/ README.md CLAUDE.md`

Expected: only the lines you just added.

- [ ] **Step 4: Commit**

```bash
git add CLAUDE.md docs/commands.md
git commit -m "docs: handle_untracked_at_startup config + smart-mode decision matrix"
```

---

## Task 7: Final verification — lint + full suite

- [ ] **Step 1: Lint**

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

Expected: 0 errors.

- [ ] **Step 2: Full test suite (no e2e)**

Run: `uv run pytest tests/ --ignore=tests/e2e/test_pool_e2e.py -q --cov=claw_forge --cov-report=term`

Expected: all PASS, coverage ≥ 90%.

- [ ] **Step 3: Mypy on modified files**

Run: `uv run mypy claw_forge/git/cleanup.py claw_forge/cli.py --ignore-missing-imports`

Expected: 0 errors.

- [ ] **Step 4: Final commit if fix-up needed**

If any of the above surfaced issues, fix them and commit:

```bash
git add -u
git commit -m "chore: lint/type fix for smart-untracked-handler"
```

---

## Self-Review Checklist

**Spec coverage:**
- ✅ Hard gate (no worktree creation until clean) — Task 4 step 4 (`raise typer.Exit(1)` on abort)
- ✅ Smart decision matrix — Task 1 (`decide_untracked_action`) + Task 3 (`smart_handle_untracked_in_project_dir`)
- ✅ Provenance preservation — Task 2 (`_archive_one` writes `PROVENANCE.json`)
- ✅ Reversibility — Task 2 + Task 3 (always move to archive, never delete)
- ✅ Three config modes (`ignore`/`smart`/`abort`) — Task 4
- ✅ Recency threshold configurable — Task 4 (`git.recent_untracked_threshold_hours`)
- ✅ Default `ignore` for one-version migration — Task 5
- ✅ Init scaffold surfaces the key — Task 5
- ✅ Docs (CLAUDE.md + docs/commands.md) — Task 6
- ✅ Lint + tests + mypy gate — Task 7

**Type consistency:**
- `UntrackedAction` (Literal) used identically in Task 1 dataclass and Task 3 dispatch.
- `UntrackedDecision` fields (`path`, `action`, `reason`, `matched_glob`) match across Task 1 (definition), Task 2 (`_archive_one` reads them), Task 3 (composition writes them).
- `UntrackedHandlingResult.proceed` is a `@property` derived from `aborts`, used identically in Task 3 tests and Task 4 cli wiring.
- `recent_threshold_seconds` (float, seconds) is the unit on the function boundary; `git.recent_untracked_threshold_hours` (config, hours) is converted with `* 3600` at the cli wiring point.

**No placeholders.** Every step has executable code or commands.

**Migration safety:** Default `ignore` ships in v0.8.13 (no behaviour change for any existing user).  Default flips to `smart` in v0.9.0 with the matching release note.  Both opt-in modes are tested in Task 4.
