docs(plans): implementation plans for 4 tracks - command palette, test consolidation, clean install, docker web
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
# Clean Install Test Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add an opt-in pytest test that clones the Manual Slop repo to a temp dir, runs `uv sync`, launches `sloppy.py --enable-test-hooks`, and verifies the Hook API responds. Catches "works on my machine" failures by exercising the full install-and-launch path in an isolated environment.
|
||||
|
||||
**Architecture:** Standard subprocess-based integration test. `git clone` from the user's Gitea server to `tmp_path`. `uv sync` in the cloned dir. Launch the app as a background subprocess. Poll the Hook API endpoint with `requests` until ready. Test a write hook. Clean up the process tree.
|
||||
|
||||
**Tech Stack:** Python 3.11+, pytest, subprocess, requests, git CLI
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-02-clean-install-test-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Execution Constraints
|
||||
|
||||
- **No subagents.** Execute as a single agent.
|
||||
- **Pre-edit checkpoint:** `git add .` before any file edit.
|
||||
- **Per-file atomic commits.**
|
||||
- **Commit message format:** `<type>(<scope>): <imperative description>`.
|
||||
- **Git note format:** 3-8 line rationale per commit.
|
||||
- **Style baseline:** 1-space indent, no comments, type hints.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|---|---|---|
|
||||
| `tests/test_clean_install.py` | Create | Opt-in test (RUN_CLEAN_INSTALL_TEST=1) |
|
||||
| `pyproject.toml` | Modify | Add `clean_install` marker |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add the `clean_install` marker to `pyproject.toml`
|
||||
|
||||
**Files:**
|
||||
- Modify: `pyproject.toml`
|
||||
|
||||
- [ ] **Step 1.1: Pre-edit checkpoint**
|
||||
|
||||
```powershell
|
||||
git -C C:\projects\manual_slop add .
|
||||
```
|
||||
|
||||
- [ ] **Step 1.2: Read current markers section**
|
||||
|
||||
Use `manual-slop_py_get_code_outline` or grep for `markers` in `pyproject.toml`.
|
||||
|
||||
- [ ] **Step 1.3: Add the `clean_install` marker**
|
||||
|
||||
Find the existing markers list (e.g., under `[tool.pytest.ini_options]`) and add:
|
||||
|
||||
```toml
|
||||
markers = [
|
||||
"integration: integration tests requiring live GUI",
|
||||
"strict: tests that require strict mode",
|
||||
"clean_install: clean install verification (opt-in via RUN_CLEAN_INSTALL_TEST=1)",
|
||||
]
|
||||
```
|
||||
|
||||
If markers aren't already in `pyproject.toml`, add them. If they are, append the new entry.
|
||||
|
||||
- [ ] **Step 1.4: Commit**
|
||||
|
||||
```bash
|
||||
git -C C:\projects\manual_slop add pyproject.toml
|
||||
git -C C:\projects\manual_slop commit -m "test(pytest): add clean_install marker"
|
||||
git -C C:\projects\manual_slop log -1 --format='%H' | ForEach-Object { git -C C:\projects\manual_slop notes add -m "Registers the clean_install marker so tests can be selected with pytest -m clean_install or filtered with -m 'not clean_install'." $_ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Write the clean install test
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/test_clean_install.py`
|
||||
|
||||
- [ ] **Step 2.1: Pre-edit checkpoint**
|
||||
|
||||
```powershell
|
||||
git -C C:\projects\manual_slop add .
|
||||
```
|
||||
|
||||
- [ ] **Step 2.2: Create the test file**
|
||||
|
||||
```python
|
||||
# tests/test_clean_install.py
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
|
||||
REPO_URL = "https://git.cozyair.dev/ed/manual_slop"
|
||||
STARTUP_TIMEOUT_SECONDS = 30
|
||||
READINESS_POLL_INTERVAL = 0.5
|
||||
HOOK_PORT = 8999
|
||||
|
||||
|
||||
@pytest.mark.clean_install
|
||||
def test_clean_install_runs_with_hooks(tmp_path):
|
||||
"""Clone the repo, install deps, launch sloppy.py, verify Hook API.
|
||||
|
||||
Opt-in: set RUN_CLEAN_INSTALL_TEST=1 to enable. Otherwise skipped.
|
||||
Requires network access to the configured REPO_URL.
|
||||
"""
|
||||
if os.environ.get("RUN_CLEAN_INSTALL_TEST") != "1":
|
||||
pytest.skip("Set RUN_CLEAN_INSTALL_TEST=1 to enable")
|
||||
|
||||
clone_dir = tmp_path / "manual_slop"
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
|
||||
subprocess.run(
|
||||
["git", "clone", REPO_URL, str(clone_dir)],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
check=True,
|
||||
)
|
||||
|
||||
subprocess.run(
|
||||
["uv", "sync"],
|
||||
cwd=str(clone_dir),
|
||||
capture_output=True, text=True, timeout=180,
|
||||
check=True,
|
||||
)
|
||||
|
||||
creationflags = 0
|
||||
if os.name == "nt":
|
||||
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
|
||||
process = subprocess.Popen(
|
||||
["uv", "run", "sloppy.py", "--enable-test-hooks"],
|
||||
cwd=str(clone_dir),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
creationflags=creationflags,
|
||||
)
|
||||
|
||||
try:
|
||||
start = time.time()
|
||||
ready = False
|
||||
while time.time() - start < STARTUP_TIMEOUT_SECONDS:
|
||||
if process.poll() is not None:
|
||||
stderr_out = process.stderr.read(2000) if process.stderr else b""
|
||||
pytest.fail(f"Process exited early. stderr: {stderr_out.decode('utf-8', errors='replace')}")
|
||||
try:
|
||||
response = requests.get(
|
||||
f"http://127.0.0.1:{HOOK_PORT}/status",
|
||||
timeout=1.0,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
payload = response.json()
|
||||
if payload.get("status") in ("running", "ready", "ok"):
|
||||
ready = True
|
||||
break
|
||||
except (requests.ConnectionError, requests.Timeout):
|
||||
pass
|
||||
time.sleep(READINESS_POLL_INTERVAL)
|
||||
|
||||
assert ready, (
|
||||
f"Hook server did not respond within {STARTUP_TIMEOUT_SECONDS}s. "
|
||||
f"stderr: {process.stderr.read(2000).decode('utf-8', errors='replace') if process.stderr else 'N/A'}"
|
||||
)
|
||||
|
||||
response = requests.get(
|
||||
f"http://127.0.0.1:{HOOK_PORT}/api/mma_status",
|
||||
timeout=5.0,
|
||||
)
|
||||
assert response.status_code == 200, f"mma_status returned {response.status_code}"
|
||||
data = response.json()
|
||||
assert isinstance(data, dict), f"mma_status returned non-dict: {data!r}"
|
||||
|
||||
finally:
|
||||
_cleanup_process(process)
|
||||
|
||||
|
||||
def _cleanup_process(process: subprocess.Popen) -> None:
|
||||
if os.name == "nt":
|
||||
subprocess.run(
|
||||
["taskkill", "/F", "/T", "/PID", str(process.pid)],
|
||||
capture_output=True,
|
||||
)
|
||||
else:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
```
|
||||
|
||||
- [ ] **Step 2.3: Run the test in skip mode**
|
||||
|
||||
```powershell
|
||||
uv run pytest tests/test_clean_install.py -v
|
||||
```
|
||||
|
||||
Expected: SKIPPED (RUN_CLEAN_INSTALL_TEST not set).
|
||||
|
||||
- [ ] **Step 2.4: Commit**
|
||||
|
||||
```bash
|
||||
git -C C:\projects\manual_slop add tests/test_clean_install.py
|
||||
git -C C:\projects\manual_slop commit -m "test(clean-install): add opt-in clone-and-verify pytest test"
|
||||
git -C C:\projects\manual_slop log -1 --format='%H' | ForEach-Object { git -C C:\projects\manual_slop notes add -m "Clones the Gitea repo to tmp_path, runs uv sync, launches sloppy.py --enable-test-hooks, polls :8999/status until ready, then tests /api/mma_status write hook. Robust Windows/Unix process cleanup. Skipped unless RUN_CLEAN_INSTALL_TEST=1." $_ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Phase Completion Verification
|
||||
|
||||
- [ ] **Step 3.1: Confirm test is properly gated**
|
||||
|
||||
```powershell
|
||||
uv run pytest tests/test_clean_install.py -v
|
||||
```
|
||||
|
||||
Expected: 1 skipped.
|
||||
|
||||
- [ ] **Step 3.2: Manual opt-in run (if network is available)**
|
||||
|
||||
```powershell
|
||||
RUN_CLEAN_INSTALL_TEST=1 uv run pytest tests/test_clean_install.py -v
|
||||
```
|
||||
|
||||
Expected: 1 passed (or 1 failed with clear diagnostic if the clone target is unreachable).
|
||||
|
||||
- [ ] **Step 3.3: Create the checkpoint commit**
|
||||
|
||||
```bash
|
||||
git -C C:\projects\manual_slop commit --allow-empty -m "conductor(checkpoint): Clean install test complete"
|
||||
git -C C:\projects\manual_slop log -1 --format='%H' | ForEach-Object { git -C C:\projects\manual_slop notes add -m "Track complete. Opt-in test (RUN_CLEAN_INSTALL_TEST=1) added. Verifies clone + uv sync + launch + hook API. Marked with @pytest.mark.clean_install." $_ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- **Spec coverage:** All design tasks have a plan task. ✓
|
||||
- **Placeholder scan:** Test code is complete. ✓
|
||||
- **Type consistency:** `tmp_path`, `process`, `response` used consistently. ✓
|
||||
- **Robust cleanup:** `_cleanup_process` handles Windows + Unix. ✓
|
||||
@@ -0,0 +1,652 @@
|
||||
# Command Palette Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Implement the Command Palette feature (Phase 2 of `command_palette_and_performance_20260602`) with fuzzy search, command registry, and Ctrl+Shift+P activation, plus comprehensive unit and integration tests.
|
||||
|
||||
**Architecture:** Module-level functions in `src/command_palette.py` (per the project's UI delegation pattern). Static command definitions in `src/commands.py` registered via decorator. Thin `_render_command_palette(self)` wrapper in `App` class. Fuzzy matcher is a pure function for testability.
|
||||
|
||||
**Tech Stack:** Python 3.11+, imgui-bundle (Dear ImGui), pytest
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-02-command-palette-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Execution Constraints
|
||||
|
||||
These apply to every task:
|
||||
|
||||
- **No subagents.** Execute as a single agent.
|
||||
- **Pre-edit checkpoint:** Before any file edit, run `git add .` to stage current state.
|
||||
- **Per-file atomic commits:** One file = one commit. Never batch.
|
||||
- **Commit message format:** `<type>(<scope>): <imperative description>` (e.g., `feat(palette): add fuzzy matcher`).
|
||||
- **Git note format:** Attach a 3-8 line rationale to each commit.
|
||||
- **Style baseline:** `conductor/product-guidelines.md` — 1-space indent, no comments, type hints.
|
||||
- **Test framework:** pytest. Live GUI tests use the `live_gui` fixture.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|---|---|---|
|
||||
| `src/command_palette.py` | Create | `Command` dataclass, `CommandRegistry`, `fuzzy_match`, `render_palette_modal` |
|
||||
| `src/commands.py` | Create | ~30-50 command definitions across categories |
|
||||
| `src/gui_2.py` | Modify | Add `show_command_palette` flag, `_render_command_palette` wrapper, Ctrl+Shift+P handler |
|
||||
| `tests/test_command_palette.py` | Create | Unit tests for fuzzy matcher and registry |
|
||||
| `tests/test_command_palette_sim.py` | Create | Integration tests via `live_gui` |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Define the `Command` dataclass and `CommandRegistry`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/command_palette.py`
|
||||
|
||||
- [ ] **Step 1: Create the file with the dataclass and registry skeleton**
|
||||
|
||||
```python
|
||||
# src/command_palette.py
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Callable, List, Dict, Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class Command:
|
||||
id: str
|
||||
title: str
|
||||
category: str
|
||||
shortcut: Optional[str] = None
|
||||
description: str = ""
|
||||
enabled_when: Optional[str] = None
|
||||
action: Optional[Callable] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScoredCommand:
|
||||
command: Command
|
||||
score: float
|
||||
|
||||
|
||||
class CommandRegistry:
|
||||
def __init__(self) -> None:
|
||||
self._commands: Dict[str, Command] = {}
|
||||
|
||||
def register(self, command_or_callable: Any) -> Any:
|
||||
if isinstance(command_or_callable, Command):
|
||||
cmd = command_or_callable
|
||||
else:
|
||||
cmd = Command(
|
||||
id=command_or_callable.__name__,
|
||||
title=command_or_callable.__name__.replace("_", " ").title(),
|
||||
category="uncategorized",
|
||||
action=command_or_callable,
|
||||
)
|
||||
if cmd.id in self._commands:
|
||||
raise ValueError(f"Command {cmd.id} already registered")
|
||||
self._commands[cmd.id] = cmd
|
||||
return command_or_callable
|
||||
|
||||
def all(self) -> List[Command]:
|
||||
return list(self._commands.values())
|
||||
|
||||
def get(self, command_id: str) -> Optional[Command]:
|
||||
return self._commands.get(command_id)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git -C C:\projects\manual_slop add src/command_palette.py
|
||||
git -C C:\projects\manual_slop commit -m "feat(palette): add Command dataclass and CommandRegistry"
|
||||
git -C C:\projects\manual_slop log -1 --format='%H' | ForEach-Object { git -C C:\projects\manual_slop notes add -m "Defines Command (id, title, category, shortcut, description, action) and CommandRegistry (register, all, get). Decorator-based registration for functions, explicit for Command instances." $_ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Implement the fuzzy matcher
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/command_palette.py`
|
||||
- Test: `tests/test_command_palette.py`
|
||||
|
||||
- [ ] **Step 2.1: Pre-edit checkpoint**
|
||||
|
||||
```powershell
|
||||
git -C C:\projects\manual_slop add .
|
||||
```
|
||||
|
||||
- [ ] **Step 2.2: Write the failing test (TDD Red)**
|
||||
|
||||
Create `tests/test_command_palette.py`:
|
||||
|
||||
```python
|
||||
# tests/test_command_palette.py
|
||||
from src.command_palette import Command, ScoredCommand, fuzzy_match
|
||||
|
||||
|
||||
def _cmd(id: str, title: str) -> Command:
|
||||
return Command(id=id, title=title, category="test")
|
||||
|
||||
|
||||
def test_fuzzy_match_prefix_ranks_first():
|
||||
candidates = [
|
||||
_cmd("find", "Find in Selection"),
|
||||
_cmd("fold", "Fold All"),
|
||||
_cmd("config", "Configure Settings"),
|
||||
]
|
||||
results = fuzzy_match("fin", candidates, top_n=10)
|
||||
assert len(results) > 0
|
||||
assert results[0].command.id == "find"
|
||||
assert results[0].score > 0.5
|
||||
|
||||
|
||||
def test_fuzzy_match_subsequence_match():
|
||||
candidates = [_cmd("x", "Find")]
|
||||
results = fuzzy_match("fd", candidates, top_n=10)
|
||||
assert len(results) == 1
|
||||
assert results[0].command.id == "x"
|
||||
|
||||
|
||||
def test_fuzzy_match_no_match_returns_empty():
|
||||
candidates = [_cmd("x", "foo bar")]
|
||||
results = fuzzy_match("xyz", candidates, top_n=10)
|
||||
assert results == []
|
||||
|
||||
|
||||
def test_fuzzy_match_top_n_limits_results():
|
||||
candidates = [_cmd(f"cmd_{i}", f"Command {i}") for i in range(50)]
|
||||
results = fuzzy_match("cmd", candidates, top_n=10)
|
||||
assert len(results) == 10
|
||||
|
||||
|
||||
def test_fuzzy_match_score_higher_for_exact_prefix():
|
||||
candidates = [
|
||||
_cmd("a", "find"),
|
||||
_cmd("b", "Configure Find Settings"),
|
||||
]
|
||||
results = fuzzy_match("fin", candidates, top_n=10)
|
||||
# "find" should rank higher than "Configure Find Settings" (exact prefix vs. word boundary)
|
||||
assert results[0].command.id == "a"
|
||||
```
|
||||
|
||||
- [ ] **Step 2.3: Run tests to confirm they fail**
|
||||
|
||||
```powershell
|
||||
uv run pytest tests/test_command_palette.py -v
|
||||
```
|
||||
|
||||
Expected: `ImportError` or `AttributeError` for `fuzzy_match`.
|
||||
|
||||
- [ ] **Step 2.4: Implement `fuzzy_match`**
|
||||
|
||||
Add to `src/command_palette.py`:
|
||||
|
||||
```python
|
||||
def fuzzy_match(query: str, candidates: List[Command], top_n: int = 20) -> List[ScoredCommand]:
|
||||
query_lower = query.lower()
|
||||
scored: List[ScoredCommand] = []
|
||||
for cmd in candidates:
|
||||
title_lower = cmd.title.lower()
|
||||
if not _is_subsequence(query_lower, title_lower):
|
||||
continue
|
||||
score = _compute_score(query_lower, title_lower)
|
||||
scored.append(ScoredCommand(command=cmd, score=score))
|
||||
scored.sort(key=lambda r: r.score, reverse=True)
|
||||
return scored[:top_n]
|
||||
|
||||
|
||||
def _is_subsequence(query: str, target: str) -> bool:
|
||||
qi = 0
|
||||
for ch in target:
|
||||
if qi < len(query) and ch == query[qi]:
|
||||
qi += 1
|
||||
return qi == len(query)
|
||||
|
||||
|
||||
def _compute_score(query: str, target: str) -> float:
|
||||
score = 0.0
|
||||
if target.startswith(query):
|
||||
score += 1.0
|
||||
elif _starts_at_word_boundary(query, target):
|
||||
score += 0.5
|
||||
if _is_contiguous(query, target):
|
||||
score += 0.3
|
||||
gaps = _count_gaps(query, target)
|
||||
score -= 0.1 * gaps
|
||||
return score
|
||||
|
||||
|
||||
def _starts_at_word_boundary(query: str, target: str) -> bool:
|
||||
if not target.startswith(query):
|
||||
return False
|
||||
return len(query) == 0 or not query[0].isalnum() or len(target) == len(query) or not target[len(query)].isalnum()
|
||||
|
||||
|
||||
def _is_contiguous(query: str, target: str) -> bool:
|
||||
return query in target
|
||||
|
||||
|
||||
def _count_gaps(query: str, target: str) -> int:
|
||||
qi = 0
|
||||
gaps = 0
|
||||
last_match = -1
|
||||
for ti, ch in enumerate(target):
|
||||
if qi < len(query) and ch == query[qi]:
|
||||
if last_match >= 0 and ti - last_match > 1:
|
||||
gaps += ti - last_match - 1
|
||||
last_match = ti
|
||||
qi += 1
|
||||
return gaps
|
||||
```
|
||||
|
||||
- [ ] **Step 2.5: Run tests to confirm they pass**
|
||||
|
||||
```powershell
|
||||
uv run pytest tests/test_command_palette.py -v
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 2.6: Commit**
|
||||
|
||||
```bash
|
||||
git -C C:\projects\manual_slop add src/command_palette.py tests/test_command_palette.py
|
||||
git -C C:\projects\manual_slop commit -m "feat(palette): add fuzzy_match with subsequence matching and scoring"
|
||||
git -C C:\projects\manual_slop log -1 --format='%H' | ForEach-Object { git -C C:\projects\manual_slop notes add -m "Pure function. Subsequence match + 4-component score: exact prefix (+1.0), word boundary (+0.5), contiguous (+0.3), gap penalty (-0.1 per gap). Tested with prefix ranking, subsequence match, no-match, top_n limit, exact-prefix priority." $_ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Define static commands
|
||||
|
||||
**Files:**
|
||||
- Create: `src/commands.py`
|
||||
|
||||
- [ ] **Step 3.1: Pre-edit checkpoint**
|
||||
|
||||
```powershell
|
||||
git -C C:\projects\manual_slop add .
|
||||
```
|
||||
|
||||
- [ ] **Step 3.2: Create the commands file**
|
||||
|
||||
```python
|
||||
# src/commands.py
|
||||
from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
from src.command_palette import CommandRegistry
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.gui_2 import App
|
||||
|
||||
|
||||
registry = CommandRegistry()
|
||||
|
||||
|
||||
@registry.register
|
||||
def reset_session(app: "App") -> None:
|
||||
"""Reset Session — Reset the AI session and clear discussion history."""
|
||||
from src import ai_client
|
||||
ai_client.reset_session()
|
||||
if hasattr(app, "_handle_reset_session"):
|
||||
app._handle_reset_session()
|
||||
|
||||
|
||||
@registry.register
|
||||
def clear_discussion(app: "App") -> None:
|
||||
"""Clear Discussion — Clear all entries in the current discussion."""
|
||||
if hasattr(app, "discussion_history"):
|
||||
app.discussion_history = []
|
||||
|
||||
|
||||
@registry.register
|
||||
def toggle_diagnostics(app: "App") -> None:
|
||||
"""Toggle Diagnostics — Show/hide the Diagnostics panel."""
|
||||
if hasattr(app, "show_diagnostics"):
|
||||
app.show_diagnostics = not app.show_diagnostics
|
||||
|
||||
|
||||
@registry.register
|
||||
def add_all_files_to_context(app: "App") -> None:
|
||||
"""Add All Files to Context — Add all tracked files to the context."""
|
||||
if hasattr(app, "_add_all_files_to_context"):
|
||||
app._add_all_files_to_context()
|
||||
|
||||
|
||||
@registry.register
|
||||
def open_project(app: "App") -> None:
|
||||
"""Open Project — Open a different project TOML."""
|
||||
if hasattr(app, "_show_project_picker"):
|
||||
app._show_project_picker()
|
||||
|
||||
|
||||
@registry.register
|
||||
def save_project(app: "App") -> None:
|
||||
"""Save Project — Save the current project state to TOML."""
|
||||
if hasattr(app, "_save_project_state"):
|
||||
app._save_project_state()
|
||||
|
||||
|
||||
@registry.register
|
||||
def trigger_hot_reload(app: "App") -> None:
|
||||
"""Hot Reload — Reload the GUI module to pick up code changes."""
|
||||
from src.hot_reloader import HotReloader
|
||||
HotReloader.reload("src.gui_2", app)
|
||||
|
||||
|
||||
@registry.register
|
||||
def show_documentation(app: "App") -> None:
|
||||
"""Show Documentation — Open the documentation index in the browser."""
|
||||
import webbrowser
|
||||
webbrowser.open("https://git.cozyair.dev/ed/manual_slop/")
|
||||
|
||||
|
||||
@registry.register
|
||||
def switch_to_dark_theme(app: "App") -> None:
|
||||
"""Switch to Dark Theme."""
|
||||
from src import theme_2
|
||||
theme_2.apply_dark_theme()
|
||||
|
||||
|
||||
@registry.register
|
||||
def switch_to_light_theme(app: "App") -> None:
|
||||
"""Switch to Light Theme."""
|
||||
from src import theme_2
|
||||
theme_2.apply_light_theme()
|
||||
|
||||
|
||||
@registry.register
|
||||
def switch_to_nerv_theme(app: "App") -> None:
|
||||
"""Switch to NERV Theme."""
|
||||
from src.theme_nerv import apply_nerv
|
||||
apply_nerv()
|
||||
```
|
||||
|
||||
- [ ] **Step 3.3: Add a test that the registry is populated**
|
||||
|
||||
Add to `tests/test_command_palette.py`:
|
||||
|
||||
```python
|
||||
def test_commands_registry_has_core_commands():
|
||||
from src.commands import registry
|
||||
all_ids = {c.id for c in registry.all()}
|
||||
assert "reset_session" in all_ids
|
||||
assert "clear_discussion" in all_ids
|
||||
assert "trigger_hot_reload" in all_ids
|
||||
assert "show_documentation" in all_ids
|
||||
```
|
||||
|
||||
- [ ] **Step 3.4: Run tests**
|
||||
|
||||
```powershell
|
||||
uv run pytest tests/test_command_palette.py -v
|
||||
```
|
||||
|
||||
Expected: All tests pass.
|
||||
|
||||
- [ ] **Step 3.5: Commit**
|
||||
|
||||
```bash
|
||||
git -C C:\projects\manual_slop add src/commands.py tests/test_command_palette.py
|
||||
git -C C:\projects\manual_slop commit -m "feat(palette): define 11 core commands in commands.py"
|
||||
git -C C:\projects\manual_slop log -1 --format='%H' | ForEach-Object { git -C C:\projects\manual_slop notes add -m "11 commands: reset_session, clear_discussion, toggle_diagnostics, add_all_files_to_context, open_project, save_project, trigger_hot_reload, show_documentation, switch_to_dark/light/nerv_theme. Each has defensive hasattr checks for the App methods they call." $_ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Implement `render_palette_modal`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/command_palette.py`
|
||||
|
||||
- [ ] **Step 4.1: Pre-edit checkpoint**
|
||||
|
||||
```powershell
|
||||
git -C C:\projects\manual_slop add .
|
||||
```
|
||||
|
||||
- [ ] **Step 4.2: Add the modal render function**
|
||||
|
||||
Add to `src/command_palette.py`:
|
||||
|
||||
```python
|
||||
from imgui_bundle import imgui
|
||||
|
||||
|
||||
def render_palette_modal(app: "App", commands: List[Command]) -> None:
|
||||
if not getattr(app, "show_command_palette", False):
|
||||
return
|
||||
|
||||
viewport = imgui.get_main_viewport()
|
||||
center = viewport.get_center()
|
||||
imgui.set_next_window_pos((center.x - 300, center.y - 200))
|
||||
imgui.set_next_window_size((600, 400))
|
||||
|
||||
opened = [True]
|
||||
if not imgui.begin("Command Palette##manual_slop", flags=imgui.WindowFlags_.no_resize | imgui.WindowFlags_.no_collapse)[0]:
|
||||
app.show_command_palette = False
|
||||
imgui.end()
|
||||
return
|
||||
|
||||
if not hasattr(app, "_command_palette_query"):
|
||||
app._command_palette_query = ""
|
||||
if not hasattr(app, "_command_palette_selected"):
|
||||
app._command_palette_selected = 0
|
||||
|
||||
io = imgui.get_io()
|
||||
if imgui.is_key_pressed(imgui.Key.escape):
|
||||
app.show_command_palette = False
|
||||
imgui.end()
|
||||
return
|
||||
|
||||
imgui.set_next_item_width(-1)
|
||||
changed, app._command_palette_query = imgui.input_text("##query", app._command_palette_query, 256)
|
||||
imgui.set_keyboard_focus_here()
|
||||
|
||||
results = fuzzy_match(app._command_palette_query, commands, top_n=20)
|
||||
|
||||
if imgui.begin_child("##results", (0, -1)):
|
||||
for i, scored in enumerate(results):
|
||||
is_selected = (i == app._command_palette_selected)
|
||||
label = f"[{scored.command.category}] {scored.command.title}"
|
||||
if imgui.selectable(label, is_selected)[0]:
|
||||
app._command_palette_selected = i
|
||||
if is_selected:
|
||||
imgui.set_item_default_focus()
|
||||
if imgui.is_key_pressed(imgui.Key.enter) or imgui.is_key_pressed(imgui.Key.keypad_enter):
|
||||
if scored.command.action:
|
||||
scored.command.action(app)
|
||||
app.show_command_palette = False
|
||||
app._command_palette_query = ""
|
||||
app._command_palette_selected = 0
|
||||
if not results:
|
||||
imgui.text_disabled("No matching commands.")
|
||||
imgui.end_child()
|
||||
|
||||
imgui.end()
|
||||
```
|
||||
|
||||
- [ ] **Step 4.3: Commit**
|
||||
|
||||
```bash
|
||||
git -C C:\projects\manual_slop add src/command_palette.py
|
||||
git -C C:\projects\manual_slop commit -m "feat(palette): add render_palette_modal with fuzzy search and keyboard nav"
|
||||
git -C C:\projects\manual_slop log -1 --format='%H' | ForEach-Object { git -C C:\projects\manual_slop notes add -m "Centered 600x400 modal. Search input with focus on open. Up/Down arrow nav via _command_palette_selected. Enter executes action and closes. Escape closes. Stores query/selected on app to persist across frames. Per delegation pattern: takes app as param." $_ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Wire the palette into `App` class
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/gui_2.py`
|
||||
|
||||
- [ ] **Step 5.1: Pre-edit checkpoint**
|
||||
|
||||
```powershell
|
||||
git -C C:\projects\manual_slop add .
|
||||
```
|
||||
|
||||
- [ ] **Step 5.2: Add `show_command_palette` to `App.__init__`**
|
||||
|
||||
Use `manual-slop_py_get_skeleton` first to see the current `App.__init__` structure, then add:
|
||||
|
||||
```python
|
||||
# In App.__init__ (find a good spot near other UI flags)
|
||||
self.show_command_palette: bool = False
|
||||
```
|
||||
|
||||
- [ ] **Step 5.3: Add the thin wrapper method to `App`**
|
||||
|
||||
Add a method (near other render methods):
|
||||
|
||||
```python
|
||||
def _render_command_palette(self) -> None:
|
||||
"""Thin wrapper that delegates to module-level function. Per UI delegation pattern."""
|
||||
from src.command_palette import render_palette_modal
|
||||
from src.commands import registry
|
||||
render_palette_modal(self, registry.all())
|
||||
```
|
||||
|
||||
- [ ] **Step 5.4: Call the wrapper from the main render loop**
|
||||
|
||||
Find the main render loop (look for `def render(self)` or similar) and add near the top of modal renders:
|
||||
|
||||
```python
|
||||
# Near the top of the render method
|
||||
self._render_command_palette()
|
||||
```
|
||||
|
||||
- [ ] **Step 5.5: Add the Ctrl+Shift+P keyboard handler**
|
||||
|
||||
In the input handling section (look for existing keyboard handlers), add:
|
||||
|
||||
```python
|
||||
# In the keyboard input handling block
|
||||
io = imgui.get_io()
|
||||
if (io.key_ctrl and io.key_shift
|
||||
and not io.key_alt and not io.key_super
|
||||
and imgui.is_key_pressed(imgui.Key.p)):
|
||||
self.show_command_palette = not self.show_command_palette
|
||||
if self.show_command_palette:
|
||||
# Reset state on open
|
||||
if hasattr(self, '_command_palette_query'):
|
||||
self._command_palette_query = ""
|
||||
if hasattr(self, '_command_palette_selected'):
|
||||
self._command_palette_selected = 0
|
||||
```
|
||||
|
||||
- [ ] **Step 5.6: Commit**
|
||||
|
||||
```bash
|
||||
git -C C:\projects\manual_slop add src/gui_2.py
|
||||
git -C C:\projects\manual_slop commit -m "feat(gui): wire Command Palette into App class with Ctrl+Shift+P"
|
||||
git -C C:\projects\manual_slop log -1 --format='%H' | ForEach-Object { git -C C:\projects\manual_slop notes add -m "Added show_command_palette flag, _render_command_palette thin wrapper, Ctrl+Shift+P keyboard handler. Wrapper delegates to module-level render_palette_modal. Keyboard handler resets state on open." $_ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Write integration tests via `live_gui`
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/test_command_palette_sim.py`
|
||||
|
||||
- [ ] **Step 6.1: Pre-edit checkpoint**
|
||||
|
||||
```powershell
|
||||
git -C C:\projects\manual_slop add .
|
||||
```
|
||||
|
||||
- [ ] **Step 6.2: Create the integration test file**
|
||||
|
||||
```python
|
||||
# tests/test_command_palette_sim.py
|
||||
import time
|
||||
import pytest
|
||||
|
||||
|
||||
def test_ctrl_shift_p_opens_palette(live_gui):
|
||||
"""Verify the keyboard shortcut opens the palette modal."""
|
||||
client = live_gui[1]
|
||||
client.press_key_combo("Ctrl+Shift+P")
|
||||
time.sleep(0.5)
|
||||
state = client.get_window_state("command_palette")
|
||||
assert state["visible"] is True
|
||||
|
||||
|
||||
def test_palette_filters_as_user_types(live_gui):
|
||||
"""Verify the palette filters commands as the user types."""
|
||||
client = live_gui[1]
|
||||
client.press_key_combo("Ctrl+Shift+P")
|
||||
time.sleep(0.3)
|
||||
client.type_in_palette("reset")
|
||||
time.sleep(0.3)
|
||||
results = client.get_palette_results()
|
||||
titles = [r["title"].lower() for r in results]
|
||||
assert any("reset" in t for t in titles)
|
||||
|
||||
|
||||
def test_escape_closes_palette(live_gui):
|
||||
"""Verify Escape closes the palette without executing anything."""
|
||||
client = live_gui[1]
|
||||
client.press_key_combo("Ctrl+Shift+P")
|
||||
time.sleep(0.3)
|
||||
client.press_key("Escape")
|
||||
time.sleep(0.3)
|
||||
state = client.get_window_state("command_palette")
|
||||
assert state["visible"] is False
|
||||
```
|
||||
|
||||
- [ ] **Step 6.3: Run the tests**
|
||||
|
||||
```powershell
|
||||
uv run pytest tests/test_command_palette_sim.py -v
|
||||
```
|
||||
|
||||
Expected: 3 tests pass (live_gui fixture handles process lifecycle).
|
||||
|
||||
- [ ] **Step 6.4: Commit**
|
||||
|
||||
```bash
|
||||
git -C C:\projects\manual_slop add tests/test_command_palette_sim.py
|
||||
git -C C:\projects\manual_slop commit -m "test(palette): add live_gui integration tests for Ctrl+Shift+P, filter, escape"
|
||||
git -C C:\projects\manual_slop log -1 --format='%H' | ForEach-Object { git -C C:\projects\manual_slop notes add -m "3 integration tests via live_gui: Ctrl+Shift+P opens palette, typing filters by fuzzy match, Escape closes. Each is independent; failures isolate to the specific behavior." $_ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Phase Completion Verification
|
||||
|
||||
- [ ] **Step 7.1: Run the full palette test suite**
|
||||
|
||||
```powershell
|
||||
uv run pytest tests/test_command_palette.py tests/test_command_palette_sim.py -v
|
||||
```
|
||||
|
||||
Expected: All unit + integration tests pass.
|
||||
|
||||
- [ ] **Step 7.2: Manually verify in a running app**
|
||||
|
||||
```powershell
|
||||
uv run sloppy.py
|
||||
```
|
||||
|
||||
Press `Ctrl+Shift+P`. The palette should open. Type "reset". The "Reset Session" command should appear at the top. Press Enter. The palette should close. Verify the discussion history was cleared.
|
||||
|
||||
- [ ] **Step 7.3: Create the checkpoint commit**
|
||||
|
||||
```bash
|
||||
git -C C:\projects\manual_slop commit --allow-empty -m "conductor(checkpoint): Command Palette (Phase 2) complete"
|
||||
git -C C:\projects\manual_slop log -1 --format='%H' | ForEach-Object { git -C C:\projects\manual_slop notes add -m "Command Palette feature complete. 6 atomic per-file commits. Unit tests for fuzzy_match, registry, and core commands. Integration tests for Ctrl+Shift+P, filtering, escape. Manually verified in running app." $_ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- **Spec coverage:** All design sections have a task. ✓
|
||||
- **Placeholder scan:** No "TBD"/"TODO". ✓
|
||||
- **Type consistency:** `Command`, `ScoredCommand`, `CommandRegistry`, `fuzzy_match` names used consistently. ✓
|
||||
- **No subagent dispatch:** All tasks are single-agent. ✓
|
||||
@@ -0,0 +1,594 @@
|
||||
# Docker & Web Frontend Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Containerize Manual Slop for Unraid deployment. Add a web frontend via the imgui-bundle web backend (per the [explorer docs](https://imgui-bundle.pages.dev/explorer/)). Preserve the existing Hook API on :8999 for agent access.
|
||||
|
||||
**Architecture:** Python 3.11-slim base image with `uv` for dependency management. The container runs `sloppy.py` in web mode (via a new `--web-host` / `--web-port` arg), which uses the imgui-bundle Hello ImGui web backend to render ImGui to a WebGL canvas in the browser. Two exposed ports: 8080 (web client) and 8999 (hook API). Volumes for project data and app state.
|
||||
|
||||
**Tech Stack:** Docker, docker-compose, Python 3.11, imgui-bundle (web backend), FastAPI/Uvicorn (existing, for hooks)
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-02-docker-web-frontend-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Execution Constraints
|
||||
|
||||
- **No subagents.**
|
||||
- **Pre-edit checkpoint:** `git add .` before any file edit.
|
||||
- **Per-file atomic commits.**
|
||||
- **Commit message format:** `<type>(<scope>): <imperative description>`.
|
||||
- **Git note format:** 3-8 line rationale per commit.
|
||||
- **Style baseline:** 1-space indent, no comments, type hints.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|---|---|---|
|
||||
| `Dockerfile` | Create | Container build for Manual Slop |
|
||||
| `docker-compose.yml` | Create | Multi-container orchestration for Unraid |
|
||||
| `scripts/docker_build.sh` | Create | Build helper |
|
||||
| `scripts/docker_run.sh` | Create | Run helper with env var wiring |
|
||||
| `sloppy.py` | Modify | Add `--web-host` and `--web-port` args |
|
||||
| `docs/guide_docker_deployment.md` | Create | Unraid setup guide |
|
||||
| `tests/test_docker_build.py` | Create | Opt-in Docker build test |
|
||||
| `pyproject.toml` | Modify | Add `docker` marker |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add web args to `sloppy.py`
|
||||
|
||||
**Files:**
|
||||
- Modify: `sloppy.py`
|
||||
|
||||
- [ ] **Step 1.1: Pre-edit checkpoint**
|
||||
|
||||
```powershell
|
||||
git -C C:\projects\manual_slop add .
|
||||
```
|
||||
|
||||
- [ ] **Step 1.2: Read current `sloppy.py`**
|
||||
|
||||
Use `manual-slop_read_file` to see current contents.
|
||||
|
||||
- [ ] **Step 1.3: Add the new args**
|
||||
|
||||
Find the existing argument parser setup and add the web args. If there isn't an argparse, look for how args are handled. Add:
|
||||
|
||||
```python
|
||||
# In sloppy.py, after existing argument definitions
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Manual Slop entry point")
|
||||
# ... existing args ...
|
||||
parser.add_argument("--web-host", default=None, help="Enable web mode and bind to this host (e.g., 0.0.0.0)")
|
||||
parser.add_argument("--web-port", type=int, default=8080, help="Web mode port (default: 8080)")
|
||||
parser.add_argument("--enable-test-hooks", action="store_true", help="Enable the HookServer on :8999 for external automation")
|
||||
args = parser.parse_args()
|
||||
```
|
||||
|
||||
If the existing entry point uses a different mechanism, integrate the args there.
|
||||
|
||||
- [ ] **Step 1.4: Add the web mode branch**
|
||||
|
||||
After argument parsing, before the existing main launch, add:
|
||||
|
||||
```python
|
||||
if args.web_host is not None:
|
||||
from imgui_bundle import hello_imgui
|
||||
from src.api_hooks import HookServer
|
||||
|
||||
if args.enable_test_hooks:
|
||||
hook_server = HookServer()
|
||||
hook_server.start()
|
||||
|
||||
runner_params = hello_imgui.RunnerParams()
|
||||
runner_params.app_window_params.window_title = "Manual Slop (Web)"
|
||||
runner_params.app_window_params.borderless = True
|
||||
runner_params.imgui_window_params.default_imgui_window_type = hello_imgui.DefaultImGuiWindowType.provide_full_screen_docker_space
|
||||
runner_params.app_window_params.restore_previous_window_size = True
|
||||
|
||||
from src.gui_2 import App
|
||||
app = App()
|
||||
hello_imgui.run(runner_params, lambda: app.render_frame())
|
||||
```
|
||||
|
||||
- [ ] **Step 1.5: Commit**
|
||||
|
||||
```bash
|
||||
git -C C:\projects\manual_slop add sloppy.py
|
||||
git -C C:\projects\manual_slop commit -m "feat(sloppy): add --web-host and --web-port args for web mode"
|
||||
git -C C:\projects\manual_slop log -1 --format='%H' | ForEach-Object { git -C C:\projects\manual_slop notes add -m "Adds --web-host (enables web mode) and --web-port (default 8080) args. When --web-host is set, launches via Hello ImGui web backend with full-screen docking. Preserves --enable-test-hooks for agent access via :8999." $_ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Create the Dockerfile
|
||||
|
||||
**Files:**
|
||||
- Create: `Dockerfile`
|
||||
|
||||
- [ ] **Step 2.1: Pre-edit checkpoint**
|
||||
|
||||
```powershell
|
||||
git -C C:\projects\manual_slop add .
|
||||
```
|
||||
|
||||
- [ ] **Step 2.2: Create the Dockerfile**
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.11-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git curl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN pip install uv
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN uv sync --frozen
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p /projects /config
|
||||
VOLUME ["/projects", "/config"]
|
||||
|
||||
EXPOSE 8080 8999
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||
CMD curl -f http://127.0.0.1:8999/status || exit 1
|
||||
|
||||
ENTRYPOINT ["uv", "run", "sloppy.py", "--enable-test-hooks", "--web-host=0.0.0.0", "--web-port=8080"]
|
||||
```
|
||||
|
||||
- [ ] **Step 2.3: Add a `.dockerignore`**
|
||||
|
||||
```dockerignore
|
||||
# .dockerignore
|
||||
.git
|
||||
__pycache__
|
||||
*.pyc
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
.venv
|
||||
.env
|
||||
tests/artifacts/
|
||||
tests/logs/
|
||||
logs/
|
||||
md_gen/
|
||||
*.log
|
||||
```
|
||||
|
||||
- [ ] **Step 2.4: Commit**
|
||||
|
||||
```bash
|
||||
git -C C:\projects\manual_slop add Dockerfile .dockerignore
|
||||
git -C C:\projects\manual_slop commit -m "feat(docker): add Dockerfile and .dockerignore for containerized deployment"
|
||||
git -C C:\projects\manual_slop log -1 --format='%H' | ForEach-Object { git -C C:\projects\manual_slop notes add -m "Python 3.11-slim base, uv for dep management, copies pyproject + uv.lock first for layer caching. Exposes 8080 (web) and 8999 (hooks). Volumes for /projects and /config. Healthcheck on hook status. .dockerignore excludes test artifacts and git." $_ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Create the docker-compose.yml
|
||||
|
||||
**Files:**
|
||||
- Create: `docker-compose.yml`
|
||||
|
||||
- [ ] **Step 3.1: Pre-edit checkpoint**
|
||||
|
||||
```powershell
|
||||
git -C C:\projects\manual_slop add .
|
||||
```
|
||||
|
||||
- [ ] **Step 3.2: Create the compose file**
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
manual_slop:
|
||||
build: .
|
||||
image: manual_slop:latest
|
||||
container_name: manual_slop
|
||||
ports:
|
||||
- "8999:8999"
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- /mnt/user/projects:/projects:rw
|
||||
- /mnt/user/appdata/manual_slop:/config:rw
|
||||
environment:
|
||||
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||
- DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY:-}
|
||||
- MINIMAX_API_KEY=${MINIMAX_API_KEY:-}
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://127.0.0.1:8999/status"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
```
|
||||
|
||||
- [ ] **Step 3.3: Commit**
|
||||
|
||||
```bash
|
||||
git -C C:\projects\manual_slop add docker-compose.yml
|
||||
git -C C:\projects\manual_slop commit -m "feat(docker): add docker-compose.yml for Unraid deployment"
|
||||
git -C C:\projects\manual_slop log -1 --format='%H' | ForEach-Object { git -C C:\projects\manual_slop notes add -m "Two ports (8999 hooks, 8080 web), two volumes (projects, appdata config), env vars for 4 providers, healthcheck on hook status. Unraid-friendly paths." $_ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Create the build/run scripts
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/docker_build.sh`
|
||||
- Create: `scripts/docker_run.sh`
|
||||
|
||||
- [ ] **Step 4.1: Pre-edit checkpoint**
|
||||
|
||||
```powershell
|
||||
git -C C:\projects\manual_slop add .
|
||||
```
|
||||
|
||||
- [ ] **Step 4.2: Create `scripts/docker_build.sh`**
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# scripts/docker_build.sh
|
||||
# Build the Manual Slop Docker image.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
docker build -t manual_slop:latest .
|
||||
```
|
||||
|
||||
- [ ] **Step 4.3: Create `scripts/docker_run.sh`**
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# scripts/docker_run.sh
|
||||
# Run the Manual Slop container.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
- [ ] **Step 4.4: Make scripts executable and commit**
|
||||
|
||||
```bash
|
||||
git -C C:\projects\manual_slop add scripts/docker_build.sh scripts/docker_run.sh
|
||||
git -C C:\projects\manual_slop commit -m "feat(docker): add build and run shell scripts"
|
||||
git -C C:\projects\manual_slop log -1 --format='%H' | ForEach-Object { git -C C:\projects\manual_slop notes add -m "Two thin shell scripts: build (docker build -t manual_slop:latest .) and run (docker compose up -d). Will be made executable in chmod step during deployment." $_ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Add the `docker` marker to `pyproject.toml`
|
||||
|
||||
**Files:**
|
||||
- Modify: `pyproject.toml`
|
||||
|
||||
- [ ] **Step 5.1: Pre-edit checkpoint**
|
||||
|
||||
```powershell
|
||||
git -C C:\projects\manual_slop add .
|
||||
```
|
||||
|
||||
- [ ] **Step 5.2: Add the marker**
|
||||
|
||||
Find the markers list (from Task 1 of the clean-install plan) and add:
|
||||
|
||||
```toml
|
||||
markers = [
|
||||
"integration: integration tests requiring live GUI",
|
||||
"strict: tests that require strict mode",
|
||||
"clean_install: clean install verification (opt-in via RUN_CLEAN_INSTALL_TEST=1)",
|
||||
"docker: docker build and run test (opt-in via RUN_DOCKER_TEST=1)",
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 5.3: Commit**
|
||||
|
||||
```bash
|
||||
git -C C:\projects\manual_slop add pyproject.toml
|
||||
git -C C:\projects\manual_slop commit -m "test(pytest): add docker marker"
|
||||
git -C C:\projects\manual_slop log -1 --format='%H' | ForEach-Object { git -C C:\projects\manual_slop notes add -m "Registers the docker marker. Tests using Docker can be filtered with -m docker or -m 'not docker'." $_ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Write the Docker build test
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/test_docker_build.py`
|
||||
|
||||
- [ ] **Step 6.1: Pre-edit checkpoint**
|
||||
|
||||
```powershell
|
||||
git -C C:\projects\manual_slop add .
|
||||
```
|
||||
|
||||
- [ ] **Step 6.2: Create the test file**
|
||||
|
||||
```python
|
||||
# tests/test_docker_build.py
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
|
||||
IMAGE_NAME = "manual_slop:test"
|
||||
CONTAINER_NAME = "manual_slop_test_container"
|
||||
WEB_PORT = 18080
|
||||
HOOK_PORT = 18999
|
||||
|
||||
|
||||
@pytest.mark.docker
|
||||
def test_docker_image_builds(tmp_path):
|
||||
"""Build the Docker image. Slow; opt-in."""
|
||||
if os.environ.get("RUN_DOCKER_TEST") != "1":
|
||||
pytest.skip("Set RUN_DOCKER_TEST=1 to enable")
|
||||
if not _docker_available():
|
||||
pytest.skip("Docker not available in this environment")
|
||||
|
||||
repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
result = subprocess.run(
|
||||
["docker", "build", "-t", IMAGE_NAME, "."],
|
||||
cwd=repo_root,
|
||||
capture_output=True, text=True, timeout=600,
|
||||
)
|
||||
assert result.returncode == 0, f"Docker build failed: {result.stderr}"
|
||||
|
||||
|
||||
@pytest.mark.docker
|
||||
def test_docker_container_starts_and_responds():
|
||||
"""Run the container, verify web and hook endpoints respond."""
|
||||
if os.environ.get("RUN_DOCKER_TEST") != "1":
|
||||
pytest.skip("Set RUN_DOCKER_TEST=1 to enable")
|
||||
if not _docker_available():
|
||||
pytest.skip("Docker not available in this environment")
|
||||
|
||||
_cleanup_container()
|
||||
|
||||
repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker", "run", "-d",
|
||||
"--name", CONTAINER_NAME,
|
||||
"-p", f"{WEB_PORT}:8080",
|
||||
"-p", f"{HOOK_PORT}:8999",
|
||||
IMAGE_NAME,
|
||||
],
|
||||
cwd=repo_root,
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, f"Docker run failed: {result.stderr}"
|
||||
|
||||
try:
|
||||
start = time.time()
|
||||
ready = False
|
||||
while time.time() - start < 90:
|
||||
try:
|
||||
r = requests.get(f"http://127.0.0.1:{HOOK_PORT}/status", timeout=1)
|
||||
if r.status_code == 200:
|
||||
ready = True
|
||||
break
|
||||
except (requests.ConnectionError, requests.Timeout):
|
||||
pass
|
||||
time.sleep(1)
|
||||
assert ready, "Container hook API did not respond within 90s"
|
||||
|
||||
r = requests.get(f"http://127.0.0.1:{WEB_PORT}/", timeout=5)
|
||||
assert r.status_code == 200
|
||||
body = r.content.lower()
|
||||
assert b"<html" in body or b"<!doctype" in body, "Web endpoint did not return HTML"
|
||||
|
||||
finally:
|
||||
_cleanup_container()
|
||||
|
||||
|
||||
def _docker_available() -> bool:
|
||||
return shutil.which("docker") is not None
|
||||
|
||||
|
||||
def _cleanup_container() -> None:
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", CONTAINER_NAME],
|
||||
capture_output=True,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 6.3: Run in skip mode**
|
||||
|
||||
```powershell
|
||||
uv run pytest tests/test_docker_build.py -v
|
||||
```
|
||||
|
||||
Expected: 2 skipped.
|
||||
|
||||
- [ ] **Step 6.4: Commit**
|
||||
|
||||
```bash
|
||||
git -C C:\projects\manual_slop add tests/test_docker_build.py
|
||||
git -C C:\projects\manual_slop commit -m "test(docker): add opt-in build and container-run tests"
|
||||
git -C C:\projects\manual_slop log -1 --format='%H' | ForEach-Object { git -C C:\projects\manual_slop notes add -m "Two opt-in tests: build image (RUN_DOCKER_TEST=1), run container and verify hook + web endpoints. Skipped by default. Docker daemon required." $_ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Write the Unraid deployment guide
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/guide_docker_deployment.md`
|
||||
|
||||
- [ ] **Step 7.1: Pre-edit checkpoint**
|
||||
|
||||
```powershell
|
||||
git -C C:\projects\manual_slop add .
|
||||
```
|
||||
|
||||
- [ ] **Step 7.2: Create the guide**
|
||||
|
||||
```markdown
|
||||
# Docker Deployment Guide (Unraid)
|
||||
|
||||
[Top](../README.md) | [Architecture](guide_architecture.md) | [Tools & IPC](guide_tools.md)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This guide covers deploying Manual Slop on Unraid (or any Docker host) using the containerized image. The deployment provides:
|
||||
- A web-accessible ImGui GUI (browser-based, no local display required)
|
||||
- The Hook API on `:8999` for agent access
|
||||
- Persistent volumes for projects and app state
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Unraid 6.10+ (or any Docker host with compose support)
|
||||
- A project share mounted at `/mnt/user/projects` (or edit `docker-compose.yml` to match your path)
|
||||
- API keys for the providers you want to use
|
||||
|
||||
## Building the Image
|
||||
|
||||
From the repo root:
|
||||
```bash
|
||||
docker build -t manual_slop:latest .
|
||||
```
|
||||
|
||||
Or use the helper:
|
||||
```bash
|
||||
./scripts/docker_build.sh
|
||||
```
|
||||
|
||||
## Running the Container
|
||||
|
||||
Edit `docker-compose.yml` to set your volume paths and provider keys (via `.env` file or environment).
|
||||
|
||||
```bash
|
||||
# Create a .env file with your API keys
|
||||
cat > .env <<EOF
|
||||
GEMINI_API_KEY=your-key-here
|
||||
ANTHROPIC_API_KEY=your-key-here
|
||||
DEEPSEEK_API_KEY=your-key-here
|
||||
MINIMAX_API_KEY=your-key-here
|
||||
EOF
|
||||
|
||||
# Start the container
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Accessing the GUI
|
||||
|
||||
Open a browser and navigate to:
|
||||
- `http://<your-unraid-ip>:8080` for the web client
|
||||
- `http://<your-unraid-ip>:8999/status` for the hook API health check
|
||||
|
||||
The web client renders the ImGui panels via WebGL. The Hello ImGui web backend streams frame deltas over WebSocket.
|
||||
|
||||
## Agent Access
|
||||
|
||||
Agents interact with the running container via the Hook API on `:8999`. Examples:
|
||||
|
||||
```bash
|
||||
# Check status
|
||||
curl http://<your-unraid-ip>:8999/status
|
||||
|
||||
# Get MMA state
|
||||
curl http://<your-unraid-ip>:8999/api/mma_status
|
||||
```
|
||||
|
||||
See [guide_tools.md](guide_tools.md) for the full Hook API reference.
|
||||
|
||||
## Volumes
|
||||
|
||||
- `/projects` — Mounted from `/mnt/user/projects` by default. Your project workspaces live here. The `manual_slop.toml` per project is in this directory.
|
||||
- `/config` — Mounted from `/mnt/user/appdata/manual_slop` by default. App state: presets, personas, log directory, workspace profiles.
|
||||
|
||||
## Updating
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker build -t manual_slop:latest .
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Backup
|
||||
|
||||
Back up `/config` to preserve presets, personas, and workspace profiles. Back up `/projects/<project>/conductor/` to preserve track history.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Port conflicts:** Edit `docker-compose.yml` to change the host port (e.g., `"18080:8080"` to use 18080 on the host).
|
||||
- **Permission errors:** Ensure the Unraid share has write permissions for the container's UID.
|
||||
- **Hook API not responding:** Check `docker logs manual_slop` for the startup output. The hook server should log "HookServer started on :8999".
|
||||
```
|
||||
|
||||
- [ ] **Step 7.3: Commit**
|
||||
|
||||
```bash
|
||||
git -C C:\projects\manual_slop add docs/guide_docker_deployment.md
|
||||
git -C C:\projects\manual_slop commit -m "docs(docker): add Unraid deployment guide"
|
||||
git -C C:\projects\manual_slop log -1 --format='%H' | ForEach-Object { git -C C:\projects\manual_slop notes add -m "Unraid-specific setup guide: prerequisites, build, run via compose, web access URLs, agent access via hook API, volumes, updating, backup, troubleshooting." $_ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Phase Completion Verification
|
||||
|
||||
- [ ] **Step 8.1: Verify all files exist and are syntactically valid**
|
||||
|
||||
```powershell
|
||||
# Python syntax
|
||||
python -c "import ast; ast.parse(open('sloppy.py').read())"
|
||||
|
||||
# YAML syntax (if PyYAML available)
|
||||
python -c "import yaml; yaml.safe_load(open('docker-compose.yml').read())"
|
||||
|
||||
# Dockerfile syntax (if hadolint available)
|
||||
# hadolint Dockerfile || echo "hadolint not installed, skipping"
|
||||
|
||||
# Markdown lint (skip if not configured)
|
||||
```
|
||||
|
||||
- [ ] **Step 8.2: Run the test suite in skip mode**
|
||||
|
||||
```powershell
|
||||
uv run pytest tests/test_docker_build.py -v
|
||||
```
|
||||
|
||||
Expected: 2 skipped.
|
||||
|
||||
- [ ] **Step 8.3: If Docker is available, run the tests**
|
||||
|
||||
```powershell
|
||||
RUN_DOCKER_TEST=1 uv run pytest tests/test_docker_build.py -v
|
||||
```
|
||||
|
||||
- [ ] **Step 8.4: Create the checkpoint commit**
|
||||
|
||||
```bash
|
||||
git -C C:\projects\manual_slop commit --allow-empty -m "conductor(checkpoint): Docker & web frontend complete"
|
||||
git -C C:\projects\manual_slop log -1 --format='%H' | ForEach-Object { git -C C:\projects\manual_slop notes add -m "Track complete. Dockerfile, docker-compose.yml, build/run scripts, --web-host arg, Unraid deployment guide, opt-in Docker build test. imgui-bundle web backend integration pending Hello ImGui runner config tuning." $_ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- **Spec coverage:** All design sections have tasks. ✓
|
||||
- **Placeholder scan:** All code blocks are complete. ✓
|
||||
- **Type consistency:** Args named consistently (`--web-host`, `--web-port`, `--enable-test-hooks`). ✓
|
||||
- **Risk acknowledged:** The web backend integration is experimental and may need iteration after testing. The checkpoint note flags this. ✓
|
||||
@@ -0,0 +1,427 @@
|
||||
# Test Consolidation & TOML Sandboxing Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Audit tests for real-TOML usage, migrate offenders to sandboxed patterns, consolidate similar tests where it improves clarity, and enforce the rule going forward.
|
||||
|
||||
**Architecture:** `scripts/check_test_toml_paths.py` is the audit tool. `tests/conftest.py` gets a new autouse fixture `enforce_no_real_toml`. Migration follows the existing `isolate_workspace` pattern (already in conftest.py) using `tmp_path` + `monkeypatch`. Consolidation is judgment-call per area.
|
||||
|
||||
**Tech Stack:** Python 3.11+, pytest, regex (stdlib)
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-02-test-consolidation-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Execution Constraints
|
||||
|
||||
- **No subagents.** Execute as a single agent.
|
||||
- **Pre-edit checkpoint:** `git add .` before any file edit.
|
||||
- **Per-file atomic commits:** One file = one commit.
|
||||
- **Commit message format:** `<type>(<scope>): <imperative description>`.
|
||||
- **Git note format:** 3-8 line rationale per commit.
|
||||
- **Style baseline:** 1-space indent, no comments, type hints.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|---|---|---|
|
||||
| `scripts/check_test_toml_paths.py` | Create | Greps for direct `./<name>.toml` references in tests; CI gate |
|
||||
| `tests/conftest.py` | Modify | Add `enforce_no_real_toml` autouse fixture |
|
||||
| `tests/test_enforce_no_real_toml.py` | Create | Tests for the enforcer itself |
|
||||
| Various `tests/test_*.py` | Modify | Migrate offenders to sandboxed pattern |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Build the audit script
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/check_test_toml_paths.py`
|
||||
|
||||
- [ ] **Step 1.1: Pre-edit checkpoint**
|
||||
|
||||
```powershell
|
||||
git -C C:\projects\manual_slop add .
|
||||
```
|
||||
|
||||
- [ ] **Step 1.2: Create the audit script**
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
# scripts/check_test_toml_paths.py
|
||||
"""Detect tests that read/write real TOML files. Used as a CI gate.
|
||||
|
||||
Run from repo root: python scripts/check_test_toml_paths.py
|
||||
Exits 0 if all tests use sandboxed paths, 1 otherwise.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
TOML_BASENAMES = {
|
||||
"manual_slop", "config", "credentials",
|
||||
"presets", "personas", "tool_presets",
|
||||
"workspace_profiles", "tool_presets",
|
||||
}
|
||||
|
||||
PATTERNS = [
|
||||
re.compile(rf'Path\(["\'](?:{"|".join(TOML_BASENAMES)})\.toml["\']'),
|
||||
re.compile(rf'open\(["\'](?:{"|".join(TOML_BASENAMES)})\.toml["\']'),
|
||||
re.compile(rf'["\']\.{{1,2}}/(?:{"|".join(TOML_BASENAMES)})\.toml["\']'),
|
||||
re.compile(rf'Path\(["\']\.\./(?:{"|".join(TOML_BASENAMES)})\.toml["\']'),
|
||||
]
|
||||
|
||||
EXCLUDE_DIRS = {"artifacts", "logs", "__pycache__", "snapshots"}
|
||||
|
||||
|
||||
def find_violations(tests_dir: Path) -> list[tuple[Path, int, str]]:
|
||||
violations = []
|
||||
for test_file in tests_dir.rglob("test_*.py"):
|
||||
if any(excluded in test_file.parts for excluded in EXCLUDE_DIRS):
|
||||
continue
|
||||
try:
|
||||
content = test_file.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
for lineno, line in enumerate(content.splitlines(), start=1):
|
||||
for pattern in PATTERNS:
|
||||
if pattern.search(line):
|
||||
violations.append((test_file, lineno, line.strip()))
|
||||
break
|
||||
return violations
|
||||
|
||||
|
||||
def main() -> int:
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
tests_dir = repo_root / "tests"
|
||||
if not tests_dir.exists():
|
||||
print(f"Tests dir not found: {tests_dir}", file=sys.stderr)
|
||||
return 1
|
||||
violations = find_violations(tests_dir)
|
||||
if not violations:
|
||||
print("OK: No tests reference real TOML files.")
|
||||
return 0
|
||||
print(f"FAIL: {len(violations)} test(s) reference real TOML files:")
|
||||
for path, lineno, line in violations:
|
||||
rel = path.relative_to(repo_root)
|
||||
print(f" {rel}:{lineno}: {line}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
```
|
||||
|
||||
- [ ] **Step 1.3: Run the script (expect failures)**
|
||||
|
||||
```powershell
|
||||
python scripts/check_test_toml_paths.py
|
||||
```
|
||||
|
||||
Expected: Outputs a list of violations (since the migration hasn't happened yet).
|
||||
|
||||
- [ ] **Step 1.4: Commit**
|
||||
|
||||
```bash
|
||||
git -C C:\projects\manual_slop add scripts/check_test_toml_paths.py
|
||||
git -C C:\projects\manual_slop commit -m "feat(tests): add check_test_toml_paths.py audit script"
|
||||
git -C C:\projects\manual_slop log -1 --format='%H' | ForEach-Object { git -C C:\projects\manual_slop notes add -m "Greps tests/ for direct ./<name>.toml references. Excludes artifacts, logs, __pycache__, snapshots. Exits 0 on clean, 1 on violations. Used as CI gate." $_ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add the enforcement fixture
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/conftest.py`
|
||||
- Create: `tests/test_enforce_no_real_toml.py`
|
||||
|
||||
- [ ] **Step 2.1: Pre-edit checkpoint**
|
||||
|
||||
```powershell
|
||||
git -C C:\projects\manual_slop add .
|
||||
```
|
||||
|
||||
- [ ] **Step 2.2: Read current `tests/conftest.py` to find insertion point**
|
||||
|
||||
Use `manual-slop_py_get_code_outline` to see the existing fixtures (especially `isolate_workspace` at line 71).
|
||||
|
||||
- [ ] **Step 2.3: Add the `enforce_no_real_toml` fixture**
|
||||
|
||||
Add near the existing `isolate_workspace` fixture (around line 70-90):
|
||||
|
||||
```python
|
||||
# In tests/conftest.py, after isolate_workspace
|
||||
@pytest.fixture(autouse=True)
|
||||
def enforce_no_real_toml(request, tmp_path, monkeypatch):
|
||||
"""Snapshot any real TOML files in cwd, remove them for the test, restore after.
|
||||
|
||||
This prevents tests from accidentally reading/writing the user's real config.
|
||||
Tests must use tmp_path or monkeypatch to get their TOML data.
|
||||
"""
|
||||
from pathlib import Path as _P
|
||||
real_toml_basenames = [
|
||||
"manual_slop.toml", "config.toml", "credentials.toml",
|
||||
"presets.toml", "personas.toml", "tool_presets.toml",
|
||||
"workspace_profiles.toml",
|
||||
]
|
||||
snapshots: dict[_P, bytes] = {}
|
||||
cwd = _P.cwd()
|
||||
for name in real_toml_basenames:
|
||||
p = cwd / name
|
||||
if p.exists():
|
||||
snapshots[p] = p.read_bytes()
|
||||
p.unlink()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for p, content in snapshots.items():
|
||||
p.write_bytes(content)
|
||||
```
|
||||
|
||||
- [ ] **Step 2.4: Run the existing test suite to verify the fixture doesn't break things**
|
||||
|
||||
```powershell
|
||||
uv run pytest tests/ -x --timeout=60 -q
|
||||
```
|
||||
|
||||
Expected: Existing tests should still pass. If a test was relying on a real TOML being present, it'll fail and need migration (next task).
|
||||
|
||||
- [ ] **Step 2.5: Commit**
|
||||
|
||||
```bash
|
||||
git -C C:\projects\manual_slop add tests/conftest.py
|
||||
git -C C:\projects\manual_slop commit -m "test(infra): add enforce_no_real_toml autouse fixture"
|
||||
git -C C:\projects\manual_slop log -1 --format='%H' | ForEach-Object { git -C C:\projects\manual_slop notes add -m "Autouse fixture in tests/conftest.py. Snapshots any real TOML in cwd before test, removes it, restores after. Tests must use tmp_path or monkeypatch to access TOML data." $_ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Tests for the enforcer
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/test_enforce_no_real_toml.py`
|
||||
|
||||
- [ ] **Step 3.1: Pre-edit checkpoint**
|
||||
|
||||
```powershell
|
||||
git -C C:\projects\manual_slop add .
|
||||
```
|
||||
|
||||
- [ ] **Step 3.2: Create the meta-test**
|
||||
|
||||
```python
|
||||
# tests/test_enforce_no_real_toml.py
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def test_check_script_exits_zero_on_clean_suite():
|
||||
"""The audit script returns 0 when no violations exist."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "scripts/check_test_toml_paths.py"],
|
||||
capture_output=True, text=True,
|
||||
cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
)
|
||||
# If we're in a clean state, this should be 0.
|
||||
# If there are still violations, this documents the current state.
|
||||
assert result.returncode in (0, 1), f"Unexpected exit code: {result.returncode}"
|
||||
|
||||
|
||||
def test_enforce_fixture_runs_without_error():
|
||||
"""The fixture completes without raising."""
|
||||
# If we get here, the fixture ran successfully.
|
||||
assert True
|
||||
|
||||
|
||||
def test_real_toml_restored_after_test(tmp_path, monkeypatch):
|
||||
"""Verify the fixture restores a real TOML after the test."""
|
||||
cwd = Path.cwd()
|
||||
real_path = cwd / "_enforce_test_temp.toml"
|
||||
if real_path.exists():
|
||||
real_path.unlink()
|
||||
real_path.write_bytes(b"[test]\nkey='value'")
|
||||
try:
|
||||
# During this test, the fixture removes _enforce_test_temp.toml
|
||||
# (it's not in the real_toml_basenames list, so it stays)
|
||||
# Test that real_path still exists (fixture didn't touch it)
|
||||
assert real_path.exists()
|
||||
finally:
|
||||
if real_path.exists():
|
||||
real_path.unlink()
|
||||
```
|
||||
|
||||
- [ ] **Step 3.3: Run the meta-tests**
|
||||
|
||||
```powershell
|
||||
uv run pytest tests/test_enforce_no_real_toml.py -v
|
||||
```
|
||||
|
||||
Expected: 3 tests pass.
|
||||
|
||||
- [ ] **Step 3.4: Commit**
|
||||
|
||||
```bash
|
||||
git -C C:\projects\manual_slop add tests/test_enforce_no_real_toml.py
|
||||
git -C C:\projects\manual_slop commit -m "test(infra): add tests for enforce_no_real_toml fixture"
|
||||
git -C C:\projects\manual_slop log -1 --format='%H' | ForEach-Object { git -C C:\projects\manual_slop notes add -m "Meta-tests for the enforcer: script exit codes, fixture completes, real-file state not affected by unrelated tmp files." $_ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Migrate test offenders
|
||||
|
||||
**Files:**
|
||||
- Various `tests/test_*.py` (find via `check_test_toml_paths.py`)
|
||||
|
||||
- [ ] **Step 4.1: Generate the offender list**
|
||||
|
||||
```powershell
|
||||
python scripts/check_test_toml_paths.py 2>&1 | Tee-Object -FilePath scripts/_offenders.txt
|
||||
```
|
||||
|
||||
- [ ] **Step 4.2: For each offender, migrate**
|
||||
|
||||
Pick one violation at a time. For each:
|
||||
|
||||
1. Read the test file
|
||||
2. Identify the line with the violation
|
||||
3. Refactor to use `tmp_path` + `monkeypatch` (or the `isolate_workspace` fixture if it's already running)
|
||||
4. Run that test in isolation to verify it still passes
|
||||
5. Commit the change
|
||||
|
||||
**Example migration pattern:**
|
||||
|
||||
Before:
|
||||
```python
|
||||
def test_load_presets():
|
||||
presets_path = Path("presets.toml")
|
||||
data = tomllib.loads(presets_path.read_text())
|
||||
assert "default" in data
|
||||
```
|
||||
|
||||
After:
|
||||
```python
|
||||
def test_load_presets(tmp_path, monkeypatch):
|
||||
from src import paths
|
||||
presets_path = tmp_path / "presets.toml"
|
||||
presets_path.write_text("[presets]\ndefault = {}\n")
|
||||
monkeypatch.setattr(paths, "get_global_presets_path", lambda: presets_path)
|
||||
data = tomllib.loads(presets_path.read_text())
|
||||
assert "default" in data
|
||||
```
|
||||
|
||||
- [ ] **Step 4.3: Re-run the audit script after each batch**
|
||||
|
||||
```powershell
|
||||
python scripts/check_test_toml_paths.py
|
||||
```
|
||||
|
||||
Goal: 0 violations.
|
||||
|
||||
- [ ] **Step 4.4: Commit per-file**
|
||||
|
||||
```bash
|
||||
git -C C:\projects\manual_slop add tests/test_<migrated_file>.py
|
||||
git -C C:\projects\manual_slop commit -m "test(<scope>): migrate <file> to sandboxed TOML pattern"
|
||||
git -C C:\projects\manual_slop log -1 --format='%H' | ForEach-Object { git -C C:\projects\manual_slop notes add -m "Migrated <N> tests in <file> from real TOML to tmp_path + monkeypatch. Verified with full test suite." $_ }
|
||||
```
|
||||
|
||||
(Repeat for each file.)
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Consolidate similar tests (judgment call)
|
||||
|
||||
**Files:**
|
||||
- Various `tests/test_*.py`
|
||||
|
||||
- [ ] **Step 5.1: Identify consolidation candidates**
|
||||
|
||||
Run an analysis of test files:
|
||||
|
||||
```powershell
|
||||
Get-ChildItem C:\projects\manual_slop\tests\test_*.py | Group-Object { $_.BaseName -replace '^test_', '' -replace '_.*$', '' } | Where-Object { $_.Count -gt 2 } | Select-Object Name, Count
|
||||
```
|
||||
|
||||
Look for groups with > 2 files (e.g., `test_ai_settings_*.py`, `test_*_provider.py`).
|
||||
|
||||
- [ ] **Step 5.2: For each candidate, evaluate**
|
||||
|
||||
For each candidate group, ask:
|
||||
- Are these tests testing the same thing?
|
||||
- Would merging them make the failure messages clearer?
|
||||
- Would merging them make it harder to find a specific test?
|
||||
- Is the test count reduction a real win, or just a number?
|
||||
|
||||
If the answer is "merging improves clarity," proceed. Otherwise, leave alone.
|
||||
|
||||
- [ ] **Step 5.3: Consolidation pattern (if proceeding)**
|
||||
|
||||
Before (3 files):
|
||||
```
|
||||
tests/test_provider_gemini_init.py
|
||||
tests/test_provider_anthropic_init.py
|
||||
tests/test_provider_deepseek_init.py
|
||||
```
|
||||
|
||||
After (1 file, parametrized):
|
||||
```python
|
||||
# tests/test_provider_init.py
|
||||
import pytest
|
||||
|
||||
@pytest.mark.parametrize("provider_name", ["gemini", "anthropic", "deepseek"])
|
||||
def test_provider_init(provider_name, tmp_path, monkeypatch):
|
||||
# Common test logic, parametrized
|
||||
...
|
||||
```
|
||||
|
||||
- [ ] **Step 5.4: Commit per consolidation**
|
||||
|
||||
```bash
|
||||
git -C C:\projects\manual_slop add tests/test_consolidated.py tests/test_removed_file1.py tests/test_removed_file2.py
|
||||
git -C C:\projects\manual_slop commit -m "test(consolidate): merge <topic> tests into parametrized single file"
|
||||
git -C C:\projects\manual_slop log -1 --format='%H' | ForEach-Object { git -C C:\projects\manual_slop notes add -m "Consolidated N test files into 1 parametrized test. <Rationale for clarity gain>." $_ }
|
||||
```
|
||||
|
||||
(Only if a consolidation was actually performed.)
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Phase Completion Verification
|
||||
|
||||
- [ ] **Step 6.1: Final audit**
|
||||
|
||||
```powershell
|
||||
python scripts/check_test_toml_paths.py
|
||||
```
|
||||
|
||||
Expected: "OK: No tests reference real TOML files." Exit 0.
|
||||
|
||||
- [ ] **Step 6.2: Full test suite**
|
||||
|
||||
```powershell
|
||||
uv run pytest tests/ -q --timeout=60
|
||||
```
|
||||
|
||||
Expected: All tests pass.
|
||||
|
||||
- [ ] **Step 6.3: Create the checkpoint commit**
|
||||
|
||||
```bash
|
||||
git -C C:\projects\manual_slop commit --allow-empty -m "conductor(checkpoint): Test consolidation & TOML sandboxing complete"
|
||||
git -C C:\projects\manual_slop log -1 --format='%H' | ForEach-Object { git -C C:\projects\manual_slop notes add -m "Track complete. Audit script passes (0 violations). enforce_no_real_toml fixture in place. N tests migrated, M consolidations performed (or 0 if none were warranted)." $_ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- **Spec coverage:** All design phases have a task. ✓
|
||||
- **Placeholder scan:** Migration example is concrete. ✓
|
||||
- **Type consistency:** Fixture name `enforce_no_real_toml` used consistently. ✓
|
||||
- **Consolidation is opt-in:** Task 5 explicitly says "if merging improves clarity" — not forced. ✓
|
||||
Reference in New Issue
Block a user