# E1 — `check` 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:** Deliver `satyrn-engine check --repo REPO CONTRACT` — accept one valid contract, refuse invalid YAML / impossible path / missing field with distinct stable exit codes, zero model calls and zero processes on every path. **Architecture:** A thin argparse CLI (`cli.py`) wraps a pure library seam `check()` (`check.py`) that loads and validates a YAML contract (`contract.py`) against a stable `ExitCode` enum (`exits.py`). A global autouse tripwire in `tests/conftest.py` fails any default-tier test that spawns a process or opens a network socket. **Tech Stack:** Python 3.14, `pyyaml>=6.0.3` (only runtime dep), `pytest`, `ruff`, `pyrefly`, hatchling (console script). **Spec:** `docs/superpowers/specs/2026-08-16-e1-check-design.md` ## Global Constraints - Python `>=3.14,<3.15`; runtime dependencies exactly one: `pyyaml>=6.0.3`. - Exit codes are a stable contract, never renumbered: `OK=0`, `USAGE=2`, `CONTRACT_UNREADABLE=3`, `CONTRACT_INVALID_YAML=4`, `CONTRACT_MISSING_FIELD=5`, `REPO_UNAVAILABLE=6`. Exit 1 is reserved for uncaught internal errors (never a refusal). - Default test tier: no model, no network, no subprocess — enforced by the tripwire; a refusal must print `satyrn-engine: : ` to stderr; success prints nothing. - A refusal test always has a sibling success test. - Product code (`src/`) never imports test code; import direction is `src <- tests` only. --- ### Task 1: The tripwire (build this first) **Files:** - Modify: `tests/conftest.py` - Create (temporary, removed in this task): `tests/test_planted_process.py` **Interfaces:** - Produces: an autouse pytest fixture `_no_process_or_network` that patches `subprocess` / `os` / `socket` entry points to raise `AssertionError`. Every later task runs under it. - [ ] **Step 1: Write the tripwire fixture** Replace the comment-only `tests/conftest.py` with: ```python """Test-root fixtures. The autouse tripwire below enforces binding rule 3 mechanically: the default tier must not spawn a process or open a network connection. Any test — or product code it calls — that does either is failed immediately, before it can produce a green that silently depends on a model or a shell. """ import os import socket import subprocess import pytest def _forbid(label: str): def _explode(*args, **kwargs): raise AssertionError(f"forbidden in the default test tier: {label}") return _explode @pytest.fixture(autouse=True) def _no_process_or_network(monkeypatch): """Fail any default-tier test that spawns a process or opens a socket.""" # subprocess.run/call/check_* route through Popen, but patching the # entry points too keeps the failure message specific to the call made. monkeypatch.setattr(subprocess, "Popen", _forbid("subprocess.Popen")) for name in ("run", "call", "check_call", "check_output", "getoutput", "getstatusoutput"): if hasattr(subprocess, name): monkeypatch.setattr(subprocess, name, _forbid(f"subprocess.{name}")) for name in ("system", "popen", "popen2", "popen3", "popen4"): if hasattr(os, name): monkeypatch.setattr(os, name, _forbid(f"os.{name}")) for name in ( "fork", "forkpty", "posix_spawn", "posix_spawnp", "spawnl", "spawnle", "spawnlp", "spawnlpe", "spawnv", "spawnve", "spawnvp", "spawnvpe", "execl", "execle", "execlp", "execlpe", "execv", "execve", "execvp", "execvpe", ): if hasattr(os, name): monkeypatch.setattr(os, name, _forbid(f"os.{name}")) monkeypatch.setattr(socket, "socket", _forbid("socket.socket")) ``` - [ ] **Step 2: Plant the process-spawning test** Create `tests/test_planted_process.py`: ```python import subprocess def test_planted_process_spawn_is_forbidden(): subprocess.run(["true"]) ``` - [ ] **Step 3: Prove the tripwire fails the planted test** Run: `uv run pytest tests/test_planted_process.py -v` Expected: FAIL with `AssertionError: forbidden in the default test tier: subprocess.run`. Record the failure output (this is the one-time proof the tripwire works). - [ ] **Step 4: Remove the planted test and confirm green** ```bash rm tests/test_planted_process.py uv run pytest -v ``` Expected: the existing `test_toolchain.py` passes; the tripwire stays. - [ ] **Step 5: Commit** ```bash git add tests/conftest.py git commit -m "test: tripwire forbids processes and network in the default tier" ``` --- ### Task 2: Exit-code table + project wiring **Files:** - Create: `src/satyrn_engine/exits.py` - Create: `tests/test_exits.py` - Modify: `pyproject.toml` **Interfaces:** - Produces: `satyrn_engine.exits.ExitCode` (IntEnum) with members `OK`, `USAGE`, `CONTRACT_UNREADABLE`, `CONTRACT_INVALID_YAML`, `CONTRACT_MISSING_FIELD`, `REPO_UNAVAILABLE`. - Produces: console script `satyrn-engine` → `satyrn_engine.cli:main` (the function itself lands in Task 5; the entry point is declared now and is only runnable after Task 5). - [ ] **Step 1: Write the failing test** Create `tests/test_exits.py`: ```python """The exit-code table is a stable contract; pin the numbers.""" from satyrn_engine.exits import ExitCode def test_exit_codes_are_distinct_and_stable() -> None: values = sorted(int(code) for code in ExitCode) assert values == [0, 2, 3, 4, 5, 6] ``` - [ ] **Step 2: Run it to verify it fails** Run: `uv run pytest tests/test_exits.py -v` Expected: FAIL — `ModuleNotFoundError: No module named 'satyrn_engine.exits'`. - [ ] **Step 3: Implement `exits.py`** ```python """Exit codes for the engine's CLI and library. The numeric values are a stable contract: callers (and the Pi adapter in later phases) rely on them to distinguish refusals. Do not renumber. """ from enum import IntEnum class ExitCode(IntEnum): """Stable process exit codes. ``OK`` and every refusal value are deliberate, named verdicts. Exit code 1 is deliberately absent: Python reports an uncaught exception as exit 1, so reserving it keeps a crash distinguishable from a refusal. """ OK = 0 USAGE = 2 # argparse's own exit for a malformed command line CONTRACT_UNREADABLE = 3 CONTRACT_INVALID_YAML = 4 CONTRACT_MISSING_FIELD = 5 REPO_UNAVAILABLE = 6 ``` - [ ] **Step 4: Wire the dependency and console script** In `pyproject.toml`, change `dependencies = []` to `dependencies = ["pyyaml>=6.0.3"]` and add after the `[build-system]` table: ```toml [project.scripts] satyrn-engine = "satyrn_engine.cli:main" ``` Run: `uv sync` (updates `uv.lock` and installs pyyaml). - [ ] **Step 5: Run tests to verify pass** Run: `uv run pytest -v` Expected: PASS — `test_exits` and `test_toolchain` both green. - [ ] **Step 6: Commit** ```bash git add src/satyrn_engine/exits.py tests/test_exits.py pyproject.toml uv.lock git commit -m "feat(exits): stable exit-code table and satyrn-engine entry point" ``` --- ### Task 3: Contract loading and validation **Files:** - Create: `src/satyrn_engine/contract.py` - Create: `tests/test_contract.py` - Create: `tests/fixtures/contracts/valid.yaml` - Create: `tests/fixtures/contracts/invalid.yaml` - Create: `tests/fixtures/contracts/missing-field.yaml` **Interfaces:** - Consumes: `satyrn_engine.exits.ExitCode`. - Produces: `Contract` (frozen dataclass, fields `id: str`, `task: str`); `ContractError(Exception)` with attributes `code: ExitCode`, `message: str`; `load_contract(path: Path) -> Contract` (raises `ContractError`). - [ ] **Step 1: Write the failing tests and fixtures** Create `tests/fixtures/contracts/valid.yaml`: ```yaml id: e1-smoke task: Replace the greeting text ``` Create `tests/fixtures/contracts/invalid.yaml` (malformed — unclosed flow sequence): ```yaml id: [unclosed ``` Create `tests/fixtures/contracts/missing-field.yaml`: ```yaml id: e1-no-task ``` Create `tests/test_contract.py`: ```python """Unit tests for contract loading and validation. Each refusal has a sibling success case (binding rule 4). """ from pathlib import Path import pytest from satyrn_engine.contract import Contract, ContractError, load_contract from satyrn_engine.exits import ExitCode FIXTURES = Path(__file__).parent / "fixtures" / "contracts" def test_load_valid_contract() -> None: contract = load_contract(FIXTURES / "valid.yaml") assert contract == Contract(id="e1-smoke", task="Replace the greeting text") def test_load_missing_field_is_refused() -> None: with pytest.raises(ContractError) as excinfo: load_contract(FIXTURES / "missing-field.yaml") assert excinfo.value.code is ExitCode.CONTRACT_MISSING_FIELD assert "task" in excinfo.value.message def test_load_invalid_yaml_is_refused() -> None: with pytest.raises(ContractError) as excinfo: load_contract(FIXTURES / "invalid.yaml") assert excinfo.value.code is ExitCode.CONTRACT_INVALID_YAML def test_load_unreadable_path_is_refused(tmp_path: Path) -> None: with pytest.raises(ContractError) as excinfo: load_contract(tmp_path / "missing.yaml") assert excinfo.value.code is ExitCode.CONTRACT_UNREADABLE def test_load_non_mapping_top_level_is_refused(tmp_path: Path) -> None: path = tmp_path / "list.yaml" path.write_text("- a\n- b\n", encoding="utf-8") with pytest.raises(ContractError) as excinfo: load_contract(path) assert excinfo.value.code is ExitCode.CONTRACT_MISSING_FIELD ``` - [ ] **Step 2: Run to verify failures** Run: `uv run pytest tests/test_contract.py -v` Expected: FAIL — `ModuleNotFoundError: No module named 'satyrn_engine.contract'`. - [ ] **Step 3: Implement `contract.py`** ```python """Contract loading and validation. A contract is a YAML mapping with two required string fields: ``id`` and ``task``. Loading either yields a :class:`Contract` or raises :class:`ContractError`, a named refusal carrying the stable exit code. """ from dataclasses import dataclass from pathlib import Path import yaml from .exits import ExitCode REQUIRED_FIELDS = ("id", "task") class ContractError(Exception): """A named refusal from loading or validating a contract.""" def __init__(self, code: ExitCode, message: str) -> None: super().__init__(message) self.code = code self.message = message @dataclass(frozen=True) class Contract: """A parsed, valid contract.""" id: str task: str def load_contract(path: Path) -> Contract: """Read, parse, and validate a contract at ``path``. Raises :class:`ContractError` with a stable :class:`ExitCode` for each refusal cause: unreadable file, invalid YAML, or a missing/invalid required field. """ try: text = path.read_text(encoding="utf-8") except OSError as exc: raise ContractError( ExitCode.CONTRACT_UNREADABLE, f"cannot read contract {path}: {exc}", ) from exc try: data = yaml.safe_load(text) except yaml.YAMLError as exc: raise ContractError( ExitCode.CONTRACT_INVALID_YAML, f"invalid YAML in {path}: {exc}", ) from exc if not isinstance(data, dict): raise ContractError( ExitCode.CONTRACT_MISSING_FIELD, f"top level of {path} must be a mapping, not {type(data).__name__}", ) problems = _field_problems(data) if problems: raise ContractError(ExitCode.CONTRACT_MISSING_FIELD, "; ".join(problems)) return Contract(id=data["id"], task=data["task"]) def _field_problems(data: dict) -> list[str]: problems: list[str] = [] for field in REQUIRED_FIELDS: value = data.get(field) if value is None: problems.append(f"missing required field {field!r}") elif not isinstance(value, str) or not value.strip(): problems.append(f"required field {field!r} must be a non-empty string") return problems ``` - [ ] **Step 4: Run to verify pass** Run: `uv run pytest tests/test_contract.py -v` Expected: PASS (5 tests). - [ ] **Step 5: Commit** ```bash git add src/satyrn_engine/contract.py tests/test_contract.py tests/fixtures/contracts/ git commit -m "feat(contract): load and validate YAML contracts with named refusals" ``` --- ### Task 4: The `check` seam **Files:** - Create: `src/satyrn_engine/check.py` - Create: `tests/test_check.py` **Interfaces:** - Consumes: `load_contract`, `ContractError`, `Contract` from `satyrn_engine.contract`; `ExitCode`. - Produces: `CheckResult` (frozen dataclass: `code: ExitCode`, `message: str = ""`, `contract: Contract | None = None`); `check(repo: Path, contract_path: Path) -> CheckResult`. - [ ] **Step 1: Write the failing tests** Create `tests/test_check.py`: ```python """Library-level tests for check().""" from pathlib import Path from satyrn_engine.check import check from satyrn_engine.exits import ExitCode FIXTURES = Path(__file__).parent / "fixtures" / "contracts" def test_check_accepts_valid_contract(tmp_path: Path) -> None: result = check(tmp_path, FIXTURES / "valid.yaml") assert result.code is ExitCode.OK assert result.contract is not None assert result.contract.id == "e1-smoke" def test_check_refuses_unavailable_repo(tmp_path: Path) -> None: result = check(tmp_path / "nope", FIXTURES / "valid.yaml") assert result.code is ExitCode.REPO_UNAVAILABLE assert "nope" in result.message ``` - [ ] **Step 2: Run to verify failures** Run: `uv run pytest tests/test_check.py -v` Expected: FAIL — `ModuleNotFoundError: No module named 'satyrn_engine.check'`. - [ ] **Step 3: Implement `check.py`** ```python """The ``check`` operation: accept or refuse a contract and its target repo.""" from dataclasses import dataclass from pathlib import Path from .contract import Contract, ContractError, load_contract from .exits import ExitCode @dataclass(frozen=True) class CheckResult: """The verdict of a ``check`` run.""" code: ExitCode message: str = "" contract: Contract | None = None def check(repo: Path, contract_path: Path) -> CheckResult: """Parse, validate, and path-lint, returning a named verdict. Order of checks is part of the stable behavior (see the E1 spec): contract first (read, parse, validate), then the repo path. """ try: contract = load_contract(contract_path) except ContractError as exc: return CheckResult(code=exc.code, message=exc.message) if not repo.is_dir(): return CheckResult( code=ExitCode.REPO_UNAVAILABLE, message=f"repo is not a directory: {repo}", ) return CheckResult(code=ExitCode.OK, contract=contract) ``` - [ ] **Step 4: Run to verify pass** Run: `uv run pytest tests/test_check.py -v` Expected: PASS (2 tests). - [ ] **Step 5: Commit** ```bash git add src/satyrn_engine/check.py tests/test_check.py git commit -m "feat(check): library seam returning a named verdict" ``` --- ### Task 5: The CLI **Files:** - Create: `src/satyrn_engine/cli.py` - Create: `tests/test_check_cli.py` **Interfaces:** - Consumes: `check` from `satyrn_engine.check`; `ExitCode`. - Produces: `build_parser() -> argparse.ArgumentParser`; `main(argv: Sequence[str] | None = None) -> int`. This is the console-script entry point declared in Task 2. - [ ] **Step 1: Write the failing end-to-end tests** Create `tests/test_check_cli.py`: ```python """End-to-end tests for `satyrn-engine check` through main().""" from pathlib import Path from satyrn_engine.cli import main from satyrn_engine.exits import ExitCode FIXTURES = Path(__file__).parent / "fixtures" / "contracts" VALID = FIXTURES / "valid.yaml" INVALID = FIXTURES / "invalid.yaml" MISSING_FIELD = FIXTURES / "missing-field.yaml" def test_valid_contract_accepted(tmp_path: Path, capsys) -> None: assert main(["--repo", str(tmp_path), str(VALID)]) == ExitCode.OK assert capsys.readouterr().err == "" def test_invalid_yaml_refused(tmp_path: Path, capsys) -> None: assert main(["--repo", str(tmp_path), str(INVALID)]) == ExitCode.CONTRACT_INVALID_YAML assert "CONTRACT_INVALID_YAML" in capsys.readouterr().err def test_impossible_repo_refused(tmp_path: Path, capsys) -> None: assert main(["--repo", str(tmp_path / "nope"), str(VALID)]) == ExitCode.REPO_UNAVAILABLE assert "REPO_UNAVAILABLE" in capsys.readouterr().err def test_impossible_contract_refused(tmp_path: Path, capsys) -> None: assert main(["--repo", str(tmp_path), str(tmp_path / "no-such.yaml")]) == ExitCode.CONTRACT_UNREADABLE assert "CONTRACT_UNREADABLE" in capsys.readouterr().err def test_missing_field_refused(tmp_path: Path, capsys) -> None: assert main(["--repo", str(tmp_path), str(MISSING_FIELD)]) == ExitCode.CONTRACT_MISSING_FIELD assert "CONTRACT_MISSING_FIELD" in capsys.readouterr().err def test_check_paths_make_no_process_or_model_calls(tmp_path: Path) -> None: # The autouse tripwire in conftest.py fails this test if any path # spawns a process or opens a network socket; driving every path here # asserts the invariant end to end. cases = [ (["--repo", str(tmp_path), str(VALID)], ExitCode.OK), (["--repo", str(tmp_path), str(INVALID)], ExitCode.CONTRACT_INVALID_YAML), (["--repo", str(tmp_path / "nope"), str(VALID)], ExitCode.REPO_UNAVAILABLE), (["--repo", str(tmp_path), str(tmp_path / "no-such.yaml")], ExitCode.CONTRACT_UNREADABLE), (["--repo", str(tmp_path), str(MISSING_FIELD)], ExitCode.CONTRACT_MISSING_FIELD), ] for argv, expected in cases: assert main(argv) == expected ``` - [ ] **Step 2: Run to verify failures** Run: `uv run pytest tests/test_check_cli.py -v` Expected: FAIL — `ModuleNotFoundError: No module named 'satyrn_engine.cli'`. - [ ] **Step 3: Implement `cli.py`** ```python """The ``satyrn-engine`` command-line interface.""" import argparse import sys from collections.abc import Sequence from pathlib import Path from .check import check from .exits import ExitCode def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="satyrn-engine") subparsers = parser.add_subparsers(dest="command", required=True) check_parser = subparsers.add_parser("check", help="parse and validate a contract") check_parser.add_argument("--repo", required=True, help="working-tree root; must be a directory") check_parser.add_argument("contract", help="path to the contract YAML file") return parser def main(argv: Sequence[str] | None = None) -> int: args = build_parser().parse_args(argv) result = check(Path(args.repo), Path(args.contract)) if result.code != ExitCode.OK: print(f"satyrn-engine: {result.code.name}: {result.message}", file=sys.stderr) return int(result.code) ``` - [ ] **Step 4: Run to verify pass** Run: `uv run pytest tests/test_check_cli.py -v` Expected: PASS (6 tests). - [ ] **Step 5: Commit** ```bash git add src/satyrn_engine/cli.py tests/test_check_cli.py git commit -m "feat(cli): satyrn-engine check accepts or refuses with stable exit codes" ``` --- ### Task 6: Full verification (the gate) **Files:** none (verification only). - [ ] **Step 1: Whole suite** Run: `uv run pytest` Expected: all tests green (toolchain, exits, contract, check, cli). - [ ] **Step 2: Lint and type-check** Run: `uv run ruff check .` and `uv run pyrefly check` Expected: both clean (no errors). - [ ] **Step 3: Timing gate** Run: `time uv run pytest -q` Expected: total wall-clock in single-digit seconds. - [ ] **Step 4: Console-script behavior on a checkout install** ```bash uv sync .venv/bin/satyrn-engine check --repo . tests/fixtures/contracts/valid.yaml; echo "valid -> $?" .venv/bin/satyrn-engine check --repo . tests/fixtures/contracts/invalid.yaml; echo "invalid -> $?" .venv/bin/satyrn-engine check --repo /nonexistent tests/fixtures/contracts/valid.yaml; echo "repo -> $?" .venv/bin/satyrn-engine check --repo . tests/fixtures/contracts/missing-field.yaml; echo "field -> $?" ``` Expected exit codes `0`, `4`, `6`, `5` respectively, each refusal printing a `satyrn-engine: :` line to stderr. - [ ] **Step 5: Commit any final changes** ```bash git add -A git commit -m "test: verify E1 gates end to end" ``` --- ## Self-Review - **Spec coverage:** accept (Task 5), invalid YAML (Task 3/5), impossible repo path (Task 4/5), impossible contract path (Task 3/5), missing field (Task 3/5), distinct stable codes (Task 2), zero process/model on any path (Task 1 + Task 5), single-digit seconds (Task 6), planted process test fails (Task 1). All done-when items covered. - **Placeholder scan:** none — every code step carries its full content. - **Type consistency:** `ExitCode` members, `Contract` fields (`id`, `task`), `CheckResult` fields (`code`, `message`, `contract`), `check(repo, contract_path)`, and `main(argv)` are identical across tasks.