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: <CAUSE>: <detail> 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:

"""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:

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

rm tests/test_planted_process.py
uv run pytest -v

Expected: the existing test_toolchain.py passes; the tripwire stays.

  • Step 5: Commit

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:

"""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

"""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:

[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

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:

id: e1-smoke
task: Replace the greeting text

Create tests/fixtures/contracts/invalid.yaml (malformed — unclosed flow sequence):

id: [unclosed

Create tests/fixtures/contracts/missing-field.yaml:

id: e1-no-task

Create tests/test_contract.py:

"""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

"""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

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:

"""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

"""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

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:

"""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

"""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

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

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: <CAUSE>: line to stderr.

  • Step 5: Commit any final changes

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.