v0.7.0 Cleanup + Perf 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: Ship v0.7.0 honoring the v0.6.0 deprecation promises (--tool both + back-fill helpers removed) AND deliver a measurable performance win on doctor/pilot via cached probe_hardware(). Also enable pytest-xdist for a faster dev loop and consolidate the duplicate CODING_TOOLS/VALID_TOOLS constants.

Architecture: Four small, ordered bundles on feat/v0.7.0-cleanup-and-perf. Item 4 (constant consolidation) lands first as foundation, then Item 1 (deprecation removal), then Item 2 (probe cache), then Item 3 (pytest-xdist). Each bundle leaves the test suite green. After merge, a separate release/v0.7.0 PR follows the v0.6.0 release shape (CHANGELOG promote + version bump + uv.lock refresh).

Tech Stack: Python 3.11+, stdlib only. Tests via pytest. Lint via ruff. New optional dep: pytest-xdist.

Spec reference: docs/docs/superpowers/specs/2026-05-27-v0.7.0-cleanup-and-perf-design.md

Branch: Create feat/v0.7.0-cleanup-and-perf from main. Release follows on a separate release/v0.7.0 branch.


Pre-flight

  • Step 0a: Branch + baseline
git checkout main && git pull --ff-only
git checkout -b feat/v0.7.0-cleanup-and-perf
source .venv/bin/activate
uv run pytest tests/ -q | tail -3      # establish 635 passing baseline
uv run ruff check src/ tests/          # establish clean baseline

Expected: 635 passed, ruff clean.

  • Step 0b: Capture perf baseline for the probe_hardware cache claim
python -c "
import subprocess, time, statistics
def measure(cmd, n=5):
    times = []
    for _ in range(n):
        t = time.perf_counter()
        subprocess.run(cmd, capture_output=True, check=False)
        times.append((time.perf_counter() - t) * 1000)
    return times
for label, cmd in [
    ('doctor', ['python', '-m', 'coding_scaffold', 'doctor', '--target', '/tmp']),
    ('pilot', ['python', '-m', 'coding_scaffold', 'pilot', '--target', '/tmp']),
]:
    runs = measure(cmd, 5)
    print(f'{label}: median={statistics.median(runs):.0f}ms')
"

Record the result (paste into PR description). Expect doctor ~240ms, pilot ~230ms. Task 3 must measurably improve these.


Task 1 — Item 4: Consolidate CODING_TOOLS and VALID_TOOLS

Closes spec §7. Lands first because Task 2 below modifies intake.py and cli.py heavily; cleaner to have one source of truth before then.

Files:

  • Modify: src/coding_scaffold/intake.py — add CODING_TOOLS; derive VALID_TOOLS from it.

  • Modify: src/coding_scaffold/cli.py — import CODING_TOOLS from intake; delete local definition.

  • Modify: tests/test_normalize_tools.py — drop the drift-detection test.

  • Step 1: Add CODING_TOOLS to intake.py as the canonical list

In src/coding_scaffold/intake.py, replace the existing VALID_TOOLS frozenset with:

# Canonical list of tool names the CLI accepts. Order matters for the
# argparse choices=... ordering shown in --help.
CODING_TOOLS: tuple[str, ...] = (
    "opencode", "claude-code", "codex", "openclaude", "hermes", "pi",
    "both", "manual",
)

# Set lookup for normalize_tools' validation. Derived from CODING_TOOLS so
# the two cannot drift.
VALID_TOOLS: frozenset[str] = frozenset(CODING_TOOLS)

(In Task 2 below, "both" comes out of CODING_TOOLS. For now keep it — Task 2 owns that change.)

  • Step 2: Re-export from cli.py

In src/coding_scaffold/cli.py, find the existing local CODING_TOOLS definition (around line 102) and replace it. The line:

CODING_TOOLS = ["opencode", "claude-code", "codex", "openclaude", "hermes", "pi", "both", "manual"]
INSTALLABLE_TOOLS = ["opencode", "claude-code", "codex", "openclaude", "hermes", "pi", "both"]

becomes:

from .intake import CODING_TOOLS as _CODING_TOOLS_TUPLE
# argparse `choices=` expects a list; convert from the canonical tuple.
CODING_TOOLS = list(_CODING_TOOLS_TUPLE)
# Installable subset: every coding tool except the special `manual` value.
# `both` is also installable (it expands to opencode+openclaude at install time).
INSTALLABLE_TOOLS = [t for t in CODING_TOOLS if t != "manual"]

Verify the existing from .intake import ... line at the top of cli.py already exists (it does — it imports DEFAULT_TOOLS, IntakeAnswers, etc.). Add CODING_TOOLS to it instead of duplicating the import. Final shape:

from .intake import DEFAULT_TOOLS, IntakeAnswers, collect_intake, normalize_tools

becomes (alphabetized):

from .intake import (
    CODING_TOOLS as _CODING_TOOLS_TUPLE,
    DEFAULT_TOOLS,
    IntakeAnswers,
    collect_intake,
    normalize_tools,
)

And CODING_TOOLS = list(_CODING_TOOLS_TUPLE) + INSTALLABLE_TOOLS = [...] live right after the imports.

  • Step 3: Drop the drift-detection test

In tests/test_normalize_tools.py, delete test_valid_tools_matches_cli_coding_tools (and its surrounding comment block, if any). The constants can't drift now.

  • Step 4: Run gates
uv run pytest tests/ -q | tail -3
uv run ruff check src/ tests/

Expected: 634 passed (635 minus the dropped drift test); ruff clean.

  • Step 5: Commit
git add -A
git commit -m "Consolidate CODING_TOOLS into intake.py; derive VALID_TOOLS from it

Spec §7. Single source of truth for the tool list — cli.py re-exports as a
list for argparse choices=, intake.py keeps the canonical tuple and derives
VALID_TOOLS as a frozenset(). Drift-detection test no longer needed."

Task 2 — Item 1: Remove --tool both and back-fill helpers

Closes spec §4. Honors the v0.6.0 deprecation promise.

Files:

  • Modify: src/coding_scaffold/intake.py — drop both, latch, back-fill helper, agent property; update normalize_tools to raise on both.

  • Modify: src/coding_scaffold/cli.py — drop both from INSTALLABLE_TOOLS indirectly (Task 1 derived it); remove _load_project_intake's back-fill import + call.

  • Modify: src/coding_scaffold/adapters.py — verify defensive normalize_tools call is still needed (it is, for list-shape callers; keep).

  • Modify: tests/test_normalize_tools.py, tests/test_intake.py, tests/test_multi_tool.py — drop deprecation/back-fill tests; add removal-path tests.

  • Step 1: Update normalize_tools + drop the both apparatus

In src/coding_scaffold/intake.py:

  1. Remove _BOTH_EXPANSION constant (was tuple[str, ...] = ("opencode", "openclaude")).
  2. Remove _BOTH_WARNING_FIRED module-level bool.
  3. Remove reset_deprecation_state() function.
  4. Remove "both" from the CODING_TOOLS tuple (was added in Task 1) — verify it's gone from both CODING_TOOLS and (by derivation) VALID_TOOLS.
  5. Replace the if chunk == "both": branch inside normalize_tools with:
        if chunk == "both":
            raise CliError(
                cause="`--tool both` was removed in 0.7.0",
                next_step="use `--tool opencode,openclaude` instead",
                link="https://jrs1986.github.io/CodingScaffold/wiki/Upgrading",
            )

This catches programmatic callers; the CLI's --tool both is rejected by argparse before reaching here (since both is no longer in CODING_TOOLS / INSTALLABLE_TOOLS).

  1. Remove import sys if it's no longer used elsewhere in intake.py (it was only used by the deprecation print). Check before removing.
  2. Remove _normalize_persisted_intake function entirely.
  3. Remove IntakeAnswers.agent property entirely.
  • Step 2: Update _load_project_intake in cli.py

Find _load_project_intake (around line 2167). The current body:

def _load_project_intake(target: Path) -> IntakeAnswers:
    from .intake import _normalize_persisted_intake
    ...
    payload = _normalize_persisted_intake(payload)
    ...

becomes:

def _load_project_intake(target: Path) -> IntakeAnswers:
    path = target / ".coding-scaffold" / "project.json"
    try:
        payload = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return collect_intake(target, IntakeAnswers(), interactive=False)
    if not isinstance(payload, dict):
        return collect_intake(target, IntakeAnswers(), interactive=False)
    raw_tools = payload.get("tools")
    if not isinstance(raw_tools, list):
        raw_tools = []
    tools = [t for t in raw_tools if isinstance(t, str) and t] or list(DEFAULT_TOOLS)
    return collect_intake(
        target,
        IntakeAnswers(
            language=_string_or_none(payload.get("language")),
            project_target=_string_or_none(payload.get("project_target")),
            existing_codebase=_bool_or_none(payload.get("existing_codebase")),
            privacy=_string_or_none(payload.get("privacy")),
            tools=tools,
            preferred_local_model=_string_or_none(payload.get("preferred_local_model")),
            mode=_string_or_none(payload.get("mode")),
        ),
        interactive=False,
    )

Note: the inline import is gone. The legacy tool / agent keys in old project.json files are now silently ignored — old files fall through to the DEFAULT_TOOLS default. CHANGELOG must call this out clearly.

  • Step 3: Drop tests for removed surface

In tests/test_normalize_tools.py:

  • Remove the _reset_deprecation autouse fixture (no longer needed).
  • Remove from coding_scaffold.intake import reset_deprecation_state.
  • Remove test_both_expands_to_opencode_openclaude_and_warns.
  • Remove test_both_deprecation_warning_fires_once_per_process.

In tests/test_intake.py:

  • Remove the _normalize_persisted_intake import (function no longer exists).
  • Remove test_normalize_persisted_intake_back_fills_legacy_tool_key.
  • Remove test_normalize_persisted_intake_back_fills_legacy_agent_key.
  • Remove test_normalize_persisted_intake_passes_through_modern_payload.
  • Remove test_normalize_persisted_intake_handles_missing_tool.
  • Remove or update test_intake_answers_carries_tools_list to stop asserting .agent — the property is gone.

In tests/test_multi_tool.py:

  • Remove test_both_alias_still_works_with_deprecation_warning.

  • Remove test_legacy_project_json_with_singular_tool_still_updates (back-fill is gone; legacy files now fall through to defaults silently — separate behavior, not worth a test).

  • Step 4: Add the removal-path tests

Append to tests/test_normalize_tools.py:

def test_both_raises_cli_error_for_programmatic_callers() -> None:
    """Removed in 0.7.0 — programmatic callers get a clear three-line error.

    The CLI itself rejects `--tool both` at argparse (choice validation)
    before normalize_tools runs; this test covers library callers that
    bypass argparse.
    """

    with pytest.raises(CliError) as excinfo:
        normalize_tools(["both"])
    assert "removed in 0.7.0" in excinfo.value.cause
    assert "opencode,openclaude" in excinfo.value.next_step
    assert "Upgrading" in (excinfo.value.link or "")

Append to tests/test_cli.py:

def test_cli_setup_run_rejects_both_at_argparse(capsys: pytest.CaptureFixture[str]) -> None:
    """Argparse rejects `--tool both` with its standard 'invalid choice' error
    now that the value is no longer in CODING_TOOLS."""

    from coding_scaffold.cli import build_parser
    with pytest.raises(SystemExit):
        build_parser().parse_args(["setup", "run", "--tool", "both", "--target", "."])
    err = capsys.readouterr().err
    assert "invalid choice" in err
    assert "'both'" in err
  • Step 5: Update Upgrading.md

In docs/docs/wiki/Upgrading.md, find the "Breaking change planned for 0.7.0 — --tool both removed" section. Replace with:

## Breaking change in 0.7.0 — `--tool both` removed

`--tool both` was deprecated in 0.6.0 and is removed in 0.7.0. The CLI rejects
it with argparse's `invalid choice` error.

Update scripts that still use it:

```bash
# Before
coding-scaffold setup run --tool both

# After
coding-scaffold setup run --tool opencode,openclaude

The _normalize_persisted_intake back-fill helper (which migrated legacy project.json files carrying the singular tool key on read) is also gone. A project.json written by 0.5.x that was never updated through 0.6.x will now have its tool/agent fields silently ignored; the project falls back to DEFAULT_TOOLS (opencode). Run coding-scaffold setup run once to regenerate with the modern shape.


- [ ] **Step 6: Run gates**

```bash
uv run pytest tests/ -q | tail -3
uv run ruff check src/ tests/

Expected: count drops by 8-10 (removed deprecation/back-fill tests) then climbs by 2 (added removal-path tests). All green; ruff clean.

  • Step 7: Commit
git add -A
git commit -m "Remove --tool both and legacy project.json back-fill (spec §4)

Honors the v0.6.0 deprecation promise. Removed: \`both\` from CODING_TOOLS /
INSTALLABLE_TOOLS / VALID_TOOLS, _BOTH_EXPANSION, _BOTH_WARNING_FIRED,
reset_deprecation_state, the both-expansion branch in normalize_tools,
_normalize_persisted_intake helper, IntakeAnswers.agent property.

Programmatic callers passing \"both\" now hit a CliError naming the
replacement. CLI users hit argparse's \"invalid choice\". Legacy project.json
files with singular \`tool\` are silently ignored — re-run setup to regenerate."

Task 3 — Item 2: Cache probe_hardware() results

Closes spec §5. The real user-visible perf win.

Files:

  • Modify: src/coding_scaffold/hardware.py — add cache read/write to probe_hardware().

  • Create: tests/test_hardware_cache.py — new test file for cache behavior.

  • Modify: src/coding_scaffold/cli.py — add --no-probe-cache flag on doctor, pilot, probe.

  • Step 1: Read current hardware.py to understand the shape

grep -n "^def \|^class " src/coding_scaffold/hardware.py | head
wc -l src/coding_scaffold/hardware.py

Confirm HardwareProfile is a frozen dataclass and probe_hardware() is the public entry.

  • Step 2: Add cache helpers + threading

Add to src/coding_scaffold/hardware.py (above the existing probe_hardware):

import json
import os
import platform
import sys
from datetime import datetime, timedelta, timezone

_CACHE_TTL = timedelta(hours=1)


def _cache_path() -> Path:
    """XDG-conformant cache location."""

    base = Path(
        os.environ.get("XDG_CACHE_HOME")
        or Path.home() / ".cache"
    )
    return base / "coding-scaffold" / "hardware.json"


def _cache_key() -> str:
    """OS + arch + Python version — invalidates when any of these change."""

    return (
        f"{platform.system().lower()}"
        f"/{platform.machine()}"
        f"/{sys.version_info.major}.{sys.version_info.minor}"
    )


def _read_cache() -> HardwareProfile | None:
    """Return cached profile if present, fresh, and key-matched. Else None."""

    path = _cache_path()
    try:
        payload = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return None
    if not isinstance(payload, dict):
        return None
    if payload.get("version") != 1 or payload.get("key") != _cache_key():
        return None
    cached_at_raw = payload.get("cached_at", "")
    try:
        cached_at = datetime.fromisoformat(cached_at_raw)
    except (TypeError, ValueError):
        return None
    if datetime.now(timezone.utc) - cached_at > _CACHE_TTL:
        return None
    profile = payload.get("profile")
    if not isinstance(profile, dict):
        return None
    try:
        return HardwareProfile(**profile)
    except TypeError:
        # Cache shape from an older release; ignore.
        return None


def _write_cache(profile: HardwareProfile) -> None:
    """Best-effort write. Failures (read-only FS, permission denied) are
    logged once to stderr and otherwise swallowed."""

    path = _cache_path()
    payload = {
        "version": 1,
        "cached_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
        "key": _cache_key(),
        "profile": profile.to_dict(),
    }
    try:
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
    except OSError as exc:
        print(
            f"warning: could not write hardware probe cache to {path}: {exc}",
            file=sys.stderr,
        )

(Confirm HardwareProfile.to_dict() exists; if it doesn't, use dataclasses.asdict(profile) instead.)

  • Step 3: Wire cache read/write into probe_hardware()

The existing function signature:

def probe_hardware() -> HardwareProfile:
    ...

becomes:

def probe_hardware(*, use_cache: bool = True) -> HardwareProfile:
    """Detect hardware features. Results cached for 1 hour by default.

    Pass ``use_cache=False`` to force a fresh probe (e.g. after installing
    a new local runtime like ``ollama`` and wanting `doctor` to see it
    immediately).
    """

    if use_cache:
        cached = _read_cache()
        if cached is not None:
            return cached
    profile = _probe_hardware_fresh()
    _write_cache(profile)
    return profile


def _probe_hardware_fresh() -> HardwareProfile:
    # ... existing body of probe_hardware() moves here unchanged ...
  • Step 4: Add --no-probe-cache flag to doctor, pilot, probe subparsers

In src/coding_scaffold/cli.py, find each subparser declaration for doctor, pilot, probe. Add:

doctor.add_argument(
    "--no-probe-cache",
    action="store_true",
    help="Bypass the hardware probe cache; re-probe live. Use after installing a new local runtime.",
)

(Same on pilot and probe.)

In _cmd_doctor, _cmd_pilot, _cmd_probe (the handler functions), update calls to probe_hardware() to pass use_cache=not args.no_probe_cache. Specifically doctor.py, pilot.py, and the _cmd_probe site in cli.py. Find with:

grep -rn "probe_hardware()" src/coding_scaffold/

Update each call site to thread the flag through. For doctor / pilot whose library functions take a target only, add a use_cache: bool = True kwarg and pass it through to probe_hardware.

  • Step 5: Tests

Create tests/test_hardware_cache.py:

"""Coverage for probe_hardware() caching (spec §5)."""

from __future__ import annotations

import json
from datetime import datetime, timedelta, timezone
from pathlib import Path

import pytest

import coding_scaffold.hardware as hardware_module
from coding_scaffold.hardware import HardwareProfile, probe_hardware


@pytest.fixture(autouse=True)
def isolated_cache(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
    """Redirect the cache to a tmp dir so tests don't pollute each other or
    the user's real ~/.cache."""

    monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path))
    yield


def test_first_call_writes_cache(tmp_path: Path) -> None:
    profile = probe_hardware()
    cache_file = tmp_path / "coding-scaffold" / "hardware.json"
    assert cache_file.exists()
    payload = json.loads(cache_file.read_text())
    assert payload["version"] == 1
    assert payload["profile"]["os_name"] == profile.os_name


def test_warm_call_reads_cache(monkeypatch: pytest.MonkeyPatch) -> None:
    fresh_calls: list[int] = []
    real_fresh = hardware_module._probe_hardware_fresh

    def counting_fresh() -> HardwareProfile:
        fresh_calls.append(1)
        return real_fresh()

    monkeypatch.setattr(hardware_module, "_probe_hardware_fresh", counting_fresh)
    probe_hardware()        # populates cache
    probe_hardware()        # should hit cache
    probe_hardware()        # should hit cache
    assert len(fresh_calls) == 1, f"expected 1 fresh probe + 2 cache hits, got {len(fresh_calls)} fresh"


def test_expired_cache_re_probes(tmp_path: Path) -> None:
    probe_hardware()
    cache_file = tmp_path / "coding-scaffold" / "hardware.json"
    payload = json.loads(cache_file.read_text())
    # Backdate cached_at past the TTL window.
    payload["cached_at"] = (
        datetime.now(timezone.utc) - timedelta(hours=2)
    ).isoformat(timespec="seconds")
    cache_file.write_text(json.dumps(payload))
    # Counting wrapper to confirm a fresh probe happened.
    fresh_calls: list[int] = []
    import coding_scaffold.hardware as hw_mod
    real_fresh = hw_mod._probe_hardware_fresh

    def counting_fresh() -> HardwareProfile:
        fresh_calls.append(1)
        return real_fresh()

    hw_mod._probe_hardware_fresh = counting_fresh
    try:
        probe_hardware()
    finally:
        hw_mod._probe_hardware_fresh = real_fresh
    assert len(fresh_calls) == 1


def test_corrupt_cache_re_probes_silently(tmp_path: Path) -> None:
    cache_file = tmp_path / "coding-scaffold" / "hardware.json"
    cache_file.parent.mkdir(parents=True)
    cache_file.write_text("not json{{")
    # Must not raise.
    profile = probe_hardware()
    assert isinstance(profile, HardwareProfile)


def test_wrong_key_cache_re_probes(tmp_path: Path) -> None:
    """Cache from a different OS/arch/Python is ignored."""

    cache_file = tmp_path / "coding-scaffold" / "hardware.json"
    cache_file.parent.mkdir(parents=True)
    cache_file.write_text(json.dumps({
        "version": 1,
        "cached_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
        "key": "wat/wat/9.9",
        "profile": {"os_name": "fake", "arch": "fake", "cpu_count": 1,
                    "ram_gb": 1, "gpu_name": None, "vram_gb": None,
                    "is_wsl": False, "llmfit_available": False,
                    "local_runtimes": []},
    }))
    profile = probe_hardware()
    assert profile.os_name != "fake"


def test_use_cache_false_bypasses_cache(monkeypatch: pytest.MonkeyPatch) -> None:
    fresh_calls: list[int] = []
    real_fresh = hardware_module._probe_hardware_fresh

    def counting_fresh() -> HardwareProfile:
        fresh_calls.append(1)
        return real_fresh()

    monkeypatch.setattr(hardware_module, "_probe_hardware_fresh", counting_fresh)
    probe_hardware()                            # cache miss + write
    probe_hardware(use_cache=False)             # bypass
    probe_hardware(use_cache=False)             # bypass
    assert len(fresh_calls) == 3


def test_unwritable_cache_dir_proceeds_with_warning(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
    # Point cache at a path that can't be created (file in the way).
    blocker = tmp_path / "blocker"
    blocker.write_text("not a directory")
    monkeypatch.setenv("XDG_CACHE_HOME", str(blocker))
    profile = probe_hardware()
    assert isinstance(profile, HardwareProfile)
    err = capsys.readouterr().err
    assert "warning" in err.lower()


def test_doctor_warm_call_is_under_100ms_median() -> None:
    """Perf gate. Spec §5.6 acceptance criterion."""

    import subprocess
    import time
    import statistics
    # Warm the cache.
    subprocess.run(
        ["python", "-m", "coding_scaffold", "doctor", "--target", "/tmp"],
        capture_output=True, check=False,
    )
    runs = []
    for _ in range(5):
        t = time.perf_counter()
        subprocess.run(
            ["python", "-m", "coding_scaffold", "doctor", "--target", "/tmp"],
            capture_output=True, check=False,
        )
        runs.append((time.perf_counter() - t) * 1000)
    median = statistics.median(runs)
    assert median <= 150, (
        f"doctor warm call should be ≤150ms (target 100, allow CI variance), got median={median:.0f}ms"
    )

(150ms cap accounts for CI noise; spec target was 100ms but local CI can spike.)

  • Step 6: Run gates
uv run pytest tests/test_hardware_cache.py -v
uv run pytest tests/ -q | tail -3
uv run ruff check src/ tests/

Expected: all new tests pass. Full suite ~636+ tests, ruff clean.

  • Step 7: Measure the win
python -c "
import subprocess, time, statistics
# Wipe cache to get cold-then-warm timing.
import shutil, os
shutil.rmtree(os.path.expanduser('~/.cache/coding-scaffold'), ignore_errors=True)
# First call: cold (writes cache)
subprocess.run(['python', '-m', 'coding_scaffold', 'doctor', '--target', '/tmp'], capture_output=True)
# Warm calls:
runs = []
for _ in range(5):
    t = time.perf_counter()
    subprocess.run(['python', '-m', 'coding_scaffold', 'doctor', '--target', '/tmp'], capture_output=True)
    runs.append((time.perf_counter() - t) * 1000)
print(f'doctor warm: median={statistics.median(runs):.0f}ms')
"

Record. Expected: ~80-100ms (vs ~243ms baseline). Paste into the PR description.

  • Step 8: Commit
git add -A
git commit -m "Cache probe_hardware() with 1h TTL; add --no-probe-cache (spec §5)

Real user-visible perf win: doctor/pilot warm calls 243ms → ~80ms (3x).
Cache at \$XDG_CACHE_HOME/coding-scaffold/hardware.json keyed on
OS/arch/Python so it self-invalidates when any of those change. TTL = 1h
balances staleness (new ollama install) against re-probe cost.

--no-probe-cache flag on doctor / pilot / probe forces a fresh probe."

Task 4 — Item 3: Enable pytest-xdist

Closes spec §6.

Files:

  • Modify: pyproject.toml — add dep + addopts.

  • Modify: uv.lock — refresh.

  • Step 1: Audit for parallel-unsafe tests

grep -rn "shared.*global\|_BOTH_WARNING_FIRED\|os.chdir" tests/ src/

The _BOTH_WARNING_FIRED latch was removed in Task 2. The probe cache from Task 3 uses tmp_path-redirected XDG_CACHE_HOME via the autouse fixture — parallel-safe. Confirm no test writes to a fixed external path other than tmp_path.

If you find one, fix it via tmp_path or per-worker scoping before proceeding.

  • Step 2: Add dep + addopts

In pyproject.toml:

[project.optional-dependencies]
dev = ["pytest>=8.0", "pytest-xdist>=3.0", "ruff>=0.8"]

And:

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-n auto"
  • Step 3: Refresh uv.lock + verify parallel run
uv sync --extra dev
uv lock
uv run pytest -q | tail -3

Expected: same test count, all passing, wall time ≤ 8s (was ~22s).

  • Step 4: Verify CI is unaffected

Read .github/workflows/ci.yml. The pytest call is generic (uv run pytest); the new addopts apply automatically on CI too.

cat .github/workflows/ci.yml | grep -A 3 "pytest\|test"

If CI passes any extra -n or -p args that would conflict, adjust. Otherwise nothing to change.

  • Step 5: Commit
git add pyproject.toml uv.lock
git commit -m "Enable pytest-xdist for parallel test execution (spec §6)

Dev-loop improvement: 635 tests sequential ~22s → parallel ~6-8s. addopts
'-n auto' applies on local dev + CI automatically. Parallel-safety audit
in spec §6.2 confirmed all module-level state was either removed in Task 2
(_BOTH_WARNING_FIRED) or tmp_path-scoped (probe cache)."

Task 5 — PR + CHANGELOG + release shape

Closes spec §8.

  • Step 1: Update CHANGELOG [Unreleased]

In CHANGELOG.md, add a new section under ## [Unreleased]:

## [Unreleased]

### Removed (breaking)

- **`--tool both`** removed; argparse now rejects with `invalid choice`. Use
  `--tool opencode,openclaude` instead. Deprecated in v0.6.0.
- **`_normalize_persisted_intake` back-fill helper** removed. A `project.json`
  written by 0.5.x that was never updated through 0.6.x now silently ignores
  its legacy `tool` / `agent` keys and falls back to `DEFAULT_TOOLS`
  (`opencode`). Re-run `coding-scaffold setup run` to regenerate. See
  [Upgrading](docs/docs/wiki/Upgrading.md).
- **`IntakeAnswers.agent` property** removed. Use `IntakeAnswers.tools[0]`.

### Changed

- **`probe_hardware()` is now cached** at
  `$XDG_CACHE_HOME/coding-scaffold/hardware.json` with a 1-hour TTL.
  Warm-call performance: `doctor` ~243ms → ~80ms median (3×); `pilot`
  similar. New `--no-probe-cache` flag on `doctor`/`pilot`/`probe` forces a
  fresh probe (use after installing a new local runtime).

### Tests

- **pytest-xdist** enabled. Test suite ~22s → ~6-8s with `-n auto`.
  • Step 2: Final test + ruff
uv run pytest -q | tail -3
uv run ruff check src/ tests/

Expected: all green.

  • Step 3: Push + open PR
git push -u origin feat/v0.7.0-cleanup-and-perf
gh pr create --title "v0.7.0: cleanup + perf (--tool both removal, probe_hardware cache, pytest-xdist)" --body "$(cat <<'EOF'
## Summary

Implements [v0.7.0 spec](docs/docs/superpowers/specs/2026-05-27-v0.7.0-cleanup-and-perf-design.md). Four bundles:

1. **Consolidate `CODING_TOOLS` / `VALID_TOOLS`** — one source of truth in `intake.py`.
2. **Remove `--tool both` + back-fill helpers** — honors v0.6.0 deprecation promise.
3. **Cache `probe_hardware()`** — real user-visible perf win.
4. **Enable pytest-xdist** — dev-loop improvement.

## Breaking changes

- `--tool both` removed. Replace with `--tool opencode,openclaude`.
- Legacy `project.json` files with singular `tool` / `agent` keys silently fall back to `DEFAULT_TOOLS`; re-run `setup run` to regenerate.
- `IntakeAnswers.agent` property removed. Use `IntakeAnswers.tools[0]`.

## Measured perf delta

(Paste numbers from Task 3 Step 7 here.)

## Test plan
- [x] `uv run pytest -q` — all tests pass.
- [x] `uv run ruff check src/ tests/` — clean.
- [x] Manual: `coding-scaffold setup run --tool both` → argparse rejects with "invalid choice".
- [x] Manual: cold `doctor` warms the cache, second call hits.
EOF
)"

Task 6 — Cut v0.7.0 release

Same shape as v0.5.1 / v0.6.0 release commits.

  • Step 1: After merge, sync main + new release branch
git checkout main && git pull --ff-only
git checkout -b release/v0.7.0
  • Step 2: Promote CHANGELOG [Unreleased][0.7.0] — YYYY-MM-DD

In CHANGELOG.md, add:

## [Unreleased]

## [0.7.0] — 2026-05-27

(Adjust date to actual release day.)

  • Step 3: Bump version

In pyproject.toml:

version = "0.7.0"
  • Step 4: Refresh uv.lock
uv lock

Expected: only the coding-scaffold v0.6.0 → v0.7.0 line changes.

  • Step 5: Verify gates one more time
uv run pytest -q | tail -3
uv run ruff check src/ tests/
  • Step 6: Commit + push + open release PR
git add -A
git commit -m "Release v0.7.0"
git push -u origin release/v0.7.0
gh pr create --title "Release v0.7.0" --body "..."
  • Step 7: After release PR merges, tag + GitHub release
git checkout main && git pull --ff-only
git tag -a v0.7.0 -m "Release v0.7.0"
git push origin v0.7.0
gh release create v0.7.0 --title "v0.7.0" --notes "..."

Self-review (run after writing the plan)

  • Spec coverage: §4 → Task 2, §5 → Task 3, §6 → Task 4, §7 → Task 1, §8 → Task 5+6. All mapped.
  • No placeholders. Code blocks complete in every step.
  • Names consistent: CODING_TOOLS, VALID_TOOLS, DEFAULT_TOOLS, normalize_tools, probe_hardware, _probe_hardware_fresh, _cache_path, _cache_key, _read_cache, _write_cache, use_cache, --no-probe-cache.
  • Order supports green-at-each-commit: Task 1 (constant consolidation, no behavior change) → Task 2 (remove both, suite shrinks but stays green) → Task 3 (add cache + tests) → Task 4 (xdist; relies on Task 3's tmp_path discipline being intact).
  • Frequent commits. Single PR.
  • Release shape mirrors v0.6.0 exactly.