v2.0: state enforcement, project scoping, harness integration
CI / build (push) Has been cancelled

State Enforcement (v2.0):
- .state file as single source of truth for task phase
- Approval gates for research, decomposition, design, test_design
- status.py --transition refuses illegal phase transitions
- status.py --validate-folder detects out-of-order artifacts
- status.py --audit checks all tasks for violations
- status.py --create-task is the only valid way to create tasks
- Pre-v2.0 tasks without .state are UNTRACKED -- all commands refuse them
- New --upgrade command bootstraps .state files for existing tasks

Project Scoping:
- --project flag added to all status.py commands across 16+ files
- _find_project_dir errors instead of silently falling back to ~/.automaton/
- --scope-check marks framework files OUT_OF_SCOPE when working on a project
- Dashboard handlers use stored project_root instead of re-detecting from CWD
- Prompts reference ~/.automaton/scripts/vram_detect.py (not {project}/.automaton/)

Harness Integration:
- status.py --can-edit now supports project-level checks (no --task required)
- --can-edit --file checks file scope without --task
- --json output for machine-readable harness integration
- opencode plugin (plugins/automaton-guard/plugin.ts) intercepts edit/write
- Git pre-commit hook (scripts/git-hooks/pre-commit) blocks commits without task
- Formal integration contract (contracts/harness-integration.md)

Other:
- upgrade.sh delegates to status.py --upgrade instead of manual heuristics
- Phase prompts reference --project {project} for multi-project scoping
- 200 tests passing (14 new)
This commit is contained in:
2026-06-15 14:16:46 -04:00
parent 79b783864e
commit 05c76852a2
151 changed files with 7295 additions and 632 deletions
+156
View File
@@ -86,3 +86,159 @@ def test_static_valid_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> N
assert response_status == [200]
assert any(h[0] == "Content-Type" and h[1] == "text/html" for h in response_headers)
assert handler.wfile.getvalue() == b"<html></html>"
class TestCORSAndSecurityHeaders:
"""Tests for CORS and security headers on API responses."""
def test_send_json_includes_cors(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
html_dir = tmp_path / "html"
html_dir.mkdir()
monkeypatch.setattr(DashboardHandler, "dashboard_path", html_dir)
handler = DashboardHandler.__new__(DashboardHandler)
response_headers: list[tuple[str, str]] = []
handler.send_response = lambda code: None
handler.send_header = lambda k, v: response_headers.append((k, v))
handler.end_headers = lambda: None
handler.wfile = io.BytesIO()
handler._send_json({"test": True})
header_dict = dict(response_headers)
assert header_dict.get("Access-Control-Allow-Origin") == "*"
assert header_dict.get("X-Content-Type-Options") == "nosniff"
def test_send_error_includes_cors(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
html_dir = tmp_path / "html"
html_dir.mkdir()
monkeypatch.setattr(DashboardHandler, "dashboard_path", html_dir)
handler = DashboardHandler.__new__(DashboardHandler)
response_headers: list[tuple[str, str]] = []
handler.send_response = lambda code: None
handler.send_header = lambda k, v: response_headers.append((k, v))
handler.end_headers = lambda: None
handler.wfile = io.BytesIO()
handler._send_error(404, "Not found")
header_dict = dict(response_headers)
assert header_dict.get("Access-Control-Allow-Origin") == "*"
assert header_dict.get("X-Content-Type-Options") == "nosniff"
class TestContentLengthBound:
"""Tests for POST content-length limits."""
def test_max_post_body_constant(self) -> None:
from automaton.dashboard.ui.app import MAX_POST_BODY, MAX_REVIEW_COMMENT_LENGTH
assert MAX_POST_BODY == 65536
assert MAX_REVIEW_COMMENT_LENGTH == 4096
class TestTaskCache:
"""Tests for server-side task caching."""
def test_cache_returns_tasks(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
from automaton.dashboard.ui.app import _get_cached_tasks, _task_cache
tasks_dir = tmp_path / ".automaton" / "tasks"
task_dir = tasks_dir / "my-task"
task_dir.mkdir(parents=True)
(task_dir / "SPEC.md").write_text("# Spec")
_task_cache["timestamp"] = 0.0
_task_cache["tasks"] = []
result = _get_cached_tasks(tmp_path)
assert len(result) == 1
assert result[0].name == "my-task"
def test_cache_uses_ttl(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
import time
from automaton.dashboard.ui.app import _get_cached_tasks, _task_cache, CACHE_TTL
tasks_dir = tmp_path / ".automaton" / "tasks"
task_dir = tasks_dir / "cached-task"
task_dir.mkdir(parents=True)
(task_dir / "SPEC.md").write_text("# Spec")
_task_cache["timestamp"] = 0.0
_task_cache["tasks"] = []
result1 = _get_cached_tasks(tmp_path)
assert len(result1) == 1
_task_cache["timestamp"] = time.time() + CACHE_TTL + 10
new_task = tasks_dir / "new-task"
new_task.mkdir()
(new_task / "SPEC.md").write_text("# New")
result2 = _get_cached_tasks(tmp_path)
assert len(result2) == 1
_task_cache["timestamp"] = 0.0
result3 = _get_cached_tasks(tmp_path)
assert len(result3) == 2
def test_invalidate_cache(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
import time
from automaton.dashboard.ui.app import _invalidate_task_cache, _get_cached_tasks, _task_cache
tasks_dir = tmp_path / ".automaton" / "tasks"
task_dir = tasks_dir / "inv-task"
task_dir.mkdir(parents=True)
(task_dir / "SPEC.md").write_text("# Spec")
_task_cache["timestamp"] = time.time() + 9999
_task_cache["tasks"] = []
_invalidate_task_cache()
assert _task_cache["timestamp"] == 0.0
class TestConfigEndpoint:
"""Tests for GET/PUT /api/config."""
def _make_handler(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, config=None):
from automaton.dashboard.ui.app import DashboardHandler
from automaton.dashboard.config import DashboardConfig
html_dir = tmp_path / "html"
html_dir.mkdir()
monkeypatch.setattr(DashboardHandler, "dashboard_path", html_dir)
handler = DashboardHandler.__new__(DashboardHandler)
handler.config = config or DashboardConfig()
handler.path = "/api/config"
return handler
def test_serve_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
from automaton.dashboard.config import DashboardConfig
handler = self._make_handler(tmp_path, monkeypatch)
response_data = {}
handler._send_json = lambda d: response_data.update(d)
handler._serve_config()
assert response_data["theme"] == "default"
assert response_data["auto_refresh_interval"] == 2
assert response_data["default_view"] == "board"
def test_serve_config_not_available(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
handler = self._make_handler(tmp_path, monkeypatch)
handler.config = None
errors = []
handler._send_error = lambda c, m: errors.append((c, m))
handler._serve_config()
assert errors[0][0] == 503
def test_handle_config_update(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
from automaton.dashboard.config import DashboardConfig
from automaton.dashboard.ui.app import DashboardHandler
tasks_dir = tmp_path / ".automaton" / "tasks"
tasks_dir.mkdir(parents=True)
monkeypatch.setattr("automaton.dashboard.ui.app.get_config_path",
lambda pr: tmp_path / ".automaton" / "dashboard-config.json")
handler = self._make_handler(tmp_path, monkeypatch)
handler.project_root = tmp_path
handler.headers = {"Content-Length": "19"}
handler.rfile = io.BytesIO(b'{"theme": "dark"}')
response_data = {}
handler._send_json = lambda d: response_data.update(d)
handler._handle_config_update()
assert response_data["theme"] == "dark"
assert handler.config.theme == "dark"
def test_handle_config_update_invalid(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
from automaton.dashboard.config import DashboardConfig
handler = self._make_handler(tmp_path, monkeypatch)
handler.headers = {"Content-Length": "39"}
handler.rfile = io.BytesIO(b'{"auto_refresh_interval": 999}')
errors = []
handler._send_error = lambda c, m: errors.append((c, m))
handler._handle_config_update()
assert errors[0][0] == 400