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 issrc <- testsonly.
Task 1: The tripwire (build this first)¶
Files:
Modify:
tests/conftest.pyCreate (temporary, removed in this task):
tests/test_planted_process.py
Interfaces:
Produces: an autouse pytest fixture
_no_process_or_networkthat patchessubprocess/os/socketentry points to raiseAssertionError. 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.pyCreate:
tests/test_exits.pyModify:
pyproject.toml
Interfaces:
Produces:
satyrn_engine.exits.ExitCode(IntEnum) with membersOK,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.pyCreate:
tests/test_contract.pyCreate:
tests/fixtures/contracts/valid.yamlCreate:
tests/fixtures/contracts/invalid.yamlCreate:
tests/fixtures/contracts/missing-field.yaml
Interfaces:
Consumes:
satyrn_engine.exits.ExitCode.Produces:
Contract(frozen dataclass, fieldsid: str,task: str);ContractError(Exception)with attributescode: ExitCode,message: str;load_contract(path: Path) -> Contract(raisesContractError).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.pyCreate:
tests/test_check.py
Interfaces:
Consumes:
load_contract,ContractError,Contractfromsatyrn_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.pyCreate:
tests/test_check_cli.py
Interfaces:
Consumes:
checkfromsatyrn_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:
ExitCodemembers,Contractfields (id,task),CheckResultfields (code,message,contract),check(repo, contract_path), andmain(argv)are identical across tasks.