Synced from satyrn-engine 9aecfb513be3211d5f5753bf778807ccecfd76ca; do not edit here.
See also The numbers and the satyrn-engine repository.
Using satyrn-engine
The engine ships a command-line entry point, satyrn-engine, that parses
and validates a contract. The check operation only lints the
repository path: on every path it makes no model calls and starts no processes.
The deliver operation deliberately starts Git and one caller-supplied command
inside a separate worktree. It requires a POSIX system and Git 2.36 or newer;
Git 2.36 introduced the NUL-delimited git worktree list --porcelain -z
format needed to handle every valid worktree pathname safely.
CLI Usage
Run the engine from the command line with the check subcommand:
satyrn-engine check --repo REPO CONTRACT
The derive subcommand writes a contract from a developer's request and the
repository, rather than requiring one to be hand-written:
satyrn-engine derive --repo REPO -- REQUEST...
The command accepts exactly two things:
| Argument | Kind | Meaning |
|---|---|---|
--repo REPO |
required option | the working tree root. It must exist and be a directory. |
CONTRACT |
required positional | path to the contract file. It must exist and be readable YAML. |
A contract is a YAML document whose top level is a mapping with two required fields, both non-empty strings, plus an optional mutation scope:
| Field | Meaning |
|---|---|
id |
a stable identifier for the contract (names receipts and candidates in later phases) |
task |
the description of the change to make |
writable_paths |
optional list of non-empty workspace-relative patterns; an omitted list permits no E4 mutation |
preserve |
optional list of tracked test-file paths restored from the base before every self_test run and before validation; never writable |
checks |
optional list of paths under checks/ restored and run alongside preserve, the same way |
token_budget |
optional output-token budget; when declared, deliver defaults token_limit to it and the receipt's budget.state becomes token_exhausted if the model spends past it |
turn_budget |
optional turn-count budget enforced the same way as token_budget |
Patterns use Python fnmatch semantics, including * crossing /. Unknown
extra fields remain ignored, so later phases can extend a contract by adding
keys rather than changing the parser.
Exit codes
check either accepts the contract (exit 0, no output) or refuses it
with a named cause and a one-line message on stderr:
satyrn-engine: <CAUSE>: <detail>
The exit codes are a stable contract:
| Code | Name | Meaning |
|---|---|---|
0 |
OK |
the contract was accepted |
2 |
USAGE |
malformed command line (argparse's own error) |
3 |
CONTRACT_UNREADABLE |
CONTRACT path is missing or not a readable file |
4 |
CONTRACT_INVALID_YAML |
CONTRACT is not valid YAML |
5 |
CONTRACT_MISSING_FIELD |
a required field is absent, empty, or the wrong type |
6 |
REPO_UNAVAILABLE |
--repo is missing or not a directory |
7 |
INVALID_REQUEST |
malformed versioned protocol input |
8 |
NO_CANDIDATE |
accepted delivery produced no candidate |
9 |
MUTATION_REFUSED |
accepted replacement was safely refused; JSON carries the exact cause |
10 |
ATTEMPT_FAILED |
accepted model attempt failed after preparation; artifacts are preserved when possible |
15 |
ATTEMPT_OK_FORWARD_LOST |
(attempt only) the model attempt succeeded, but its live budget tee (forward) was lost for good mid-run; deliver treats this the same as exit 0 for candidate creation and marks the receipt's budget.live_counter as "lost" |
Exit code 1 is deliberately unused: Python reports an uncaught internal
error as 1, so reserving it keeps a crash distinguishable from a refusal.
Example
From a checkout, uv sync installs the engine into the project
environment; uv run satyrn-engine ... then runs it with no further
install step. Write a contract:
# greeting.yaml
id: greeting
task: Replace the greeting text
writable_paths:
- greeting.py
Run check against the current checkout:
$ uv run satyrn-engine check --repo . greeting.yaml
$ echo $?
0
A valid contract is accepted silently. A refusal names its cause and returns its code:
$ uv run satyrn-engine check --repo . missing.yaml
satyrn-engine: CONTRACT_UNREADABLE: cannot read contract missing.yaml: [Errno 2] No such file or directory: 'missing.yaml'
$ echo $?
3
Every exit code
The committed fixtures exercise the distinct exit codes in one pass:
$ uv run satyrn-engine check --repo . tests/fixtures/contracts/valid.yaml; echo "valid -> $?"
valid -> 0
$ uv run satyrn-engine check --repo . tests/fixtures/contracts/invalid.yaml; echo "invalid -> $?"
satyrn-engine: CONTRACT_INVALID_YAML: invalid YAML in tests/fixtures/contracts/invalid.yaml: ...
invalid -> 4
$ uv run satyrn-engine check --repo /nonexistent tests/fixtures/contracts/valid.yaml; echo "repo -> $?"
satyrn-engine: REPO_UNAVAILABLE: repo is not a directory: /nonexistent
repo -> 6
$ uv run satyrn-engine check --repo . tests/fixtures/contracts/missing-field.yaml; echo "field -> $?"
satyrn-engine: CONTRACT_MISSING_FIELD: missing required field 'task'
field -> 5
Deliver one candidate
On POSIX systems, deliver runs one command in a detached
worktree isolation directory at the repository's exact HEAD:
satyrn-engine deliver --repo REPO [--timeout SECONDS] CONTRACT -- COMMAND [ARG ...]
REPO must be the clean root of a normal non-bare Git working tree with at
least one commit. A symlink to the root and a linked-worktree root are accepted;
a subdirectory is not. COMMAND is an exact argument vector after the literal
--, not a shell string. The default timeout is 30 seconds.
For example, with the greeting.yaml contract above:
$ uv run satyrn-engine deliver --repo . greeting.yaml -- \
python -c 'from pathlib import Path; Path("greeting.txt").write_text("hello\n")'
{"version":1,"outcome":"candidate-created","code":"OK",...}
$ git show refs/satyrn/candidates/greeting/head
The engine never merges or applies the result. After review, remove this E3 candidate explicitly with:
git update-ref -d refs/satyrn/candidates/greeting/head
Receipt and exit status
Every accepted delivery operation writes exactly one UTF-8 JSON receipt and newline to stdout. Command stdout and stderr go to the engine's stderr, so stdout stays machine-readable. The receipt always contains these fields:
| Field | Meaning |
|---|---|
version |
receipt schema version; E3 emits 1 |
outcome |
candidate-created, discarded, or refused; derived from code |
code |
authoritative closed vocabulary, such as OK, NO_CHANGES, or REPO_DIRTY |
message |
always-present human-readable detail; code remains authoritative |
contract_id |
parsed contract id, or null when parsing failed |
repository / base_commit |
normalized input path and captured Git commit |
candidate_ref / candidate_commit |
proposed ref and created commit when available |
changed_paths |
UTF-8 paths sorted by raw bytes; [] means known empty, null means unavailable |
command_exit |
direct command status; null when it never started or timed out |
worktree_path |
retained cleanup path requiring operator action, otherwise null |
size_refusal |
the advisory medium-class refusal text derive printed, or null when the request was within class or no request was measured |
head_moved |
true when HEAD was inspected after the command and is detached at a commit other than base_commit (for example, the model ran git commit); false when it is at base_commit, when the attempt was refused for an attached HEAD, or when HEAD was never inspected (the command failed, timed out, or never ran). The candidate is always the worktree's tree with base_commit as its only parent |
validation_output_bytes |
byte length of the self-test output captured for this delivery, or null when none ran |
Success exits 0. Contract refusals retain codes 3–6. All other handled
results that publish no candidate exit 8 (NO_CANDIDATE); automation reads
the receipt's code for the exact reason. CLI syntax errors still exit 2,
and an uncaught engine error remains exit 1 with no invented receipt.
If the caller closes stdout before reading the receipt, the engine also exits
with the reserved abnormal status 1 and suppresses a broken-pipe traceback;
candidate publication may already have completed, so callers must inspect the
candidate ref before retrying.
The complete delivery result-code vocabulary is:
| Code | Meaning |
|---|---|
CONTRACT_UNREADABLE, CONTRACT_INVALID_YAML, CONTRACT_MISSING_FIELD, REPO_UNAVAILABLE |
inherited contract or repository-path refusal |
REPO_NOT_GIT, REPO_DIRTY |
source state cannot be used |
INVALID_CANDIDATE_ID, CANDIDATE_EXISTS |
candidate identity cannot be created |
COMMAND_UNAVAILABLE, COMMAND_TIMEOUT, COMMAND_FAILED |
command did not complete successfully |
COMMAND_CHANGED_HEAD, NO_CHANGES |
command attached HEAD to a branch (a ref in the source repository), or produced no changed tree |
GIT_FAILED, CLEANUP_FAILED |
engine-owned Git or cleanup operation failed |
OK |
candidate ref was created atomically |
outcome and the numeric exit status are derived from code; callers cannot
construct contradictory combinations such as OK plus refused.
If CLEANUP_FAILED reports a registered or conservatively retained worktree,
recover it with:
git worktree unlock PATH
git worktree remove --force PATH
git worktree prune
If the retained path is only the temporary parent after Git already removed the worktree, delete that directory after inspecting it.
Git cleanup is deliberately withheld when timeout teardown cannot confirm both that the process group is gone and that the direct child was reaped. This keeps a possibly live command from racing worktree removal; the receipt reports the retained worktree for operator inspection.
Trust boundary
Worktree isolation protects the caller's working tree, index, branch, and
HEAD from ordinary command writes. It is not a container or filesystem
sandbox. COMMAND can write arbitrary absolute paths, mutate shared Git state,
or deliberately escape its POSIX process group; E3 does not prevent those
actions. Therefore it accepts only a trusted command expected to run
synchronously. Git is an explicit runtime requirement. E4's writable-path and
revision rules apply only when the attempt uses its bounded replacement tool;
E5 connects that tool to one model attempt and E3 delivery. Command output is
spooled to temporary storage
to bound engine memory and avoid a descendant-held pipe; E3 does not impose a
byte quota on that storage, just as it does not limit files written by the
trusted command itself.
Justification status (2026-09-15)
The loop breaker, scope guard, symbol preservation, command bounds (guard 4),
carried tests, self_test (its output detection and the completion gate) were
designed from evidence gathered before release one's clean harness. On live
isolated cells, guard 4 fired as designed and the self_test detection
replaced ad-hoc pytest runs, but none of these components has been shown to
change outcomes, and the completion gate never fired in cells that end at the
budget rather than stopping early. Their justification is re-opened for
release two; see
satyrn-evals/docs/superpowers/specs/2026-09-15-release-one-outcome.md.
/implement
Inside Pi with the package installed (pi install <engine>/packages/engine,
SATYRN_ENGINE_REPO and SATYRN_MODEL set):
/implement add --check to src/app/cli.py
derives a contract from the request and the repository — writable_paths
from the files, directories and new files named in the request's Files:
block alone, when the request has one; only a request with no Files: block
falls back to tokenizing the whole request (naming a non-test .py file
also makes its test file, tests/test_<stem>.py, writable, unless that test
already exists — it is then preserved instead). Restricting to the Files:
block keeps derive from admitting a path the grader would reject just
because the word appeared later in the prompt.
When the named tokens yield no writable path other than test files or test
directories (including naming nothing at all), writable_paths falls back
to the repository's top-level entries instead: every top-level tracked file
that select_carried would not carry (preserve, checks, a tracked
conftest.py at any depth, or a tracked infrastructure file such as
pyproject.toml), plus <dir>/* for every top-level tracked directory
except checks — so a request that only names tests/ (or a preserved
test file) still leaves the model free to touch source. A directory whose
only tracked files are test files and test-support files (conftest.py,
__init__.py, a pytest config) still counts as naming nothing but tests.
A named source file or directory behaves exactly as before, with no
fallback; an empty repository (no tracked files at all) is still refused.
test_command from [tool.satyrn] self_test in pyproject.toml
or the default uv run python -m pytest -q, preserve (tracked test files
under tests/) and checks (checks/), budgets of 32,000 output tokens and
48 turns — writes it under .git/satyrn/contracts/<id>.yaml, and shows it. A
repository with no pyproject.toml is refused.
derive also measures the request against the medium class: at most two
non-test paths in the Files: block, and at most ten symbols named in an
Interfaces: block's Produces: lines. Above either bound it still writes
the contract — the refusal is advisory, not a gate — and prints a refusal to
stderr naming the count and what was over, exits 0, and records the
refusal text on the receipt's size_refusal field. The refusal text never
enters the model's prompt. Because a developer can under-declare symbols in
Produces:, this is a guide for splitting a request, not an enforced limit.
In the TUI, answer the confirmation to dispatch; in print mode run:
/implement --go implement-0123456789ab
One fresh Pi runs in a worktree branched from HEAD with the guards loaded
(the loop breaker runs in every Pi session; the rest register only in that
child): the loop breaker; edit/write refused outside writable_paths; an
edit or write that would remove a symbol the base defines refused with
what to do instead; bash timeout set to 120 s when absent and clamped at
300 s, with the bound and the self-test named in a fenced note -- [satyrn-engine
note -- not part of the command's output] on its own line, then the
sentence -- appended only to the first bash result of the session and to any
result whose command timed out, so an unfenced trailing sentence is never
mistaken for a file's own last line by a model reading it back through
cat/tail/sed -n, and ordinary results carry no reminder at all; a bash
command left
exactly as the model wrote it, with pytest's summary line in its output
detected so the Engine runs its own self-test once when a source mutation has
landed since the last one (self_test_detected), its own note fenced the
same way as the bound's; and, when the model stops
with no self-test since its last edit or write, one run by the Engine
whose failure goes back to the model as a single follow-up message
(self_test_enforced). The completion gate only asks whether a self-test has
run since the last landed mutation, not whether it passed -- so a model
that runs (or has the Engine detect) a failing self-test and then stops on a
tool-call-free turn satisfies that gate silently. The red-stop gate closes
that gap: on such a turn, when the last self-test completed at the current
mutation generation did not pass, the Engine runs self_test again and,
only if that fresh run is still red, sends one follow-up
(self_test_red_stop) with its result (a pass, or a run the engine
refuses, sends nothing). It fires at most once per generation; an
enforced-gate failure follow-up on the same generation also counts as
having told the model, so it does not run a second check right after.
Diagnosed from satyrn-evals development record
records/2026-09-23-spike-mellum-class-review-script-n6.json, Engine cell
selfhost-review-script-20260923-130353-154738. When a self-test that
completed inside a turn -- the model's own self_test call, or the Engine's
run after a detected bash test run, never the enforced gate -- passes, and a
source file has changed since the last pass, the Engine sends one message
saying the change
may be complete and that commits, provenance rows, repository-wide suites,
linters and test-count edits are the developer's. When a turn hits the
per-turn output cap with no tool call, the Engine asks once for a concrete
next step, at most twice per session; it stays silent when the completion
gate or the red-stop gate has just sent its own follow-up on that turn.
preserve, checks,
tracked conftest.py files and tracked pytest configuration
(pyproject.toml, pytest.ini, setup.cfg, tox.ini) are restored from the
base into the worktree before every self_test run and before validation, so
the model's edits to them never count; carried.tampered names any of those
paths the candidate commit changed anyway (a new tests/conftest.py the
model added counts). The receipt is written to stdout and, verbatim, to
.git/satyrn/receipts/<id>.json; it adds turns, tool_calls, tokens_in,
tokens_out, guard_firings and carried; validation is the engine's own
run and is authoritative; budget.state is token_exhausted when the model
spent past its token budget and the candidate is kept — because Pi's stdout
is forwarded to attempt's own stdout live, line by line, while it runs
(not copied over only after it exits), a declared token or turn budget can
trip and end the attempt during the run rather than only label it
afterwards. The /implement notification reads as error unless the
receipt's code is OK, validation is passed, and carried.tampered is
empty — OK with validation unavailable, timed out, not run, or failed, or
with a non-empty tampered list, still reads as an error (and names the
tampered paths), since a developer must never mistake any of those for a
clean pass. self_test itself refuses with
TEST_COMMAND_UNAVAILABLE when the carried preserve/checks set cannot be
read or restored from the base.
Run one model attempt
Run attempt from the root of a clean, disposable Git worktree:
SATYRN_ATTEMPT_TRANSCRIPT=/output/transcript.jsonl \
SATYRN_ATTEMPT_PATCH=/output/patch.diff \
satyrn-engine attempt --model omlx/gemma-4-12B-it-MLX-8bit CONTRACT
The model is explicit: --model wins, then SATYRN_MODEL; omitting both is a
usage error. The command freezes the parsed contract, records exact revisions
for its tracked writable files, and starts Pi with read, native bash,
edit, write, and — only when the contract declares a test_command —
self_test (attempt.build_pi_command), alongside the loaded guards (the
loop breaker, the scope, symbol and bash-bound checks, and the self_test
runner). The bash-bound guard's fenced reminder is announced once per Pi
process (on its first bash result) and again on any bash command that times
out; every other result carries no reminder at all. Pi skills, prompt
templates, themes, context files, sessions, and
ambient extensions are disabled. The current worktree remains the model's
workspace, so direct attempt is intended for E3's disposable worktree rather
than a developer's checkout. Pi's own stdout is forwarded to attempt's
stdout line by line while Pi runs, not copied over only after it exits, so a
caller streaming that output (E3's budget enforcement) can act on it during
the run.
Both artifact paths are optional and must be absent. Their parents must be real
directories outside every registered worktree and outside Git's worktree and
common administrative directories. During preparation, attempt opens and pins
each accepted parent by filesystem identity without following symlinks; every
later publication is relative to that descriptor, and every result path tries
to close it exactly once. The transcript is Pi's exact
JSONL output. The patch is written only when the tracked tree changed. Neither
artifact is a grading verdict; they record what happened. A Pi start or
nonzero-exit failure returns
ATTEMPT_FAILED with exit 10 after preserving any requested artifacts. The
Engine forwards a direct POSIX SIGTERM (and SIGHUP, where supported) to
the active Pi child, waits for it to exit, then publishes the partial
transcript before its own failure result. This makes an outer timeout
diagnosable without treating its partial patch or transcript as a verdict.
/implement wrapper additionally gives E3 fifteen minutes to complete the
attempt. If the adapter's backstop deadline expires, it reports
ENGINE_TIMEOUT on POSIX only after the direct delivery child closes and a
signal-0 probe reports that the detached outer delivery group is gone. E3
first reaps its separately-sessioned attempt group and cleans or explicitly
retains its worktree. The adapter does not force the POSIX outer group with
SIGKILL, because doing so could interrupt that inner cleanup; an unknown or
still-present outer group leaves the refusal pending. Windows retains the
direct-child TERM/KILL/close fallback and is outside the E5 platform proof.
Tracked symlinks never enter the immutable revision map. Adapter stdin,
stdout, stderr, and diagnostic-forwarding failures are named refusals rather
than uncaught Node exceptions.
The Pi adapter
The adapter exposes the engine inside Pi as the /implement command
described in the section above (/implement <request>, confirmed in place or
dispatched with --go <id> in print mode). Once dispatched, it starts E3
deliver against the derived contract's absolute path under
.git/satyrn/contracts/. Delivery runs the same E5 attempt once in a
detached worktree. A success reports the candidate ref and commit; a refusal
reports the exact delivery receipt code and detail. A start failure,
deadline, crash, or malformed receipt is contained and reported by the
adapter rather than escaping the Pi turn.
Install the Pi package from the engine checkout:
pi install /path/to/satyrn-engine/packages/engine
export SATYRN_ENGINE_REPO=/path/to/satyrn-engine-checkout
export SATYRN_MODEL=omlx/gemma-4-12B-it-MLX-8bit
SATYRN_ENGINE_REPO names the engine checkout; the adapter starts the
engine with uv run --project $SATYRN_ENGINE_REPO satyrn-engine deliver,
passing SATYRN_MODEL to the nested attempt. Therefore uv, pi, and the
selected model provider must be installed and configured.
Install the package once, globally. Do not also load any extension with pi's
-e flag: pi then registers /implement twice and suffixes the command
(/implement:1), so the plain name stops dispatching (recorded in the
harvest index, "The /implement command vanished").
Bounded replacement
E4 adds a conditional replacement for Pi's edit tool. A normal package
install alone does not enable it: E5 supplies a versioned
SATYRN_MUTATION_CONTEXT containing the disposable workspace, contract, and
captured revisions. Without that context, Pi keeps its built-in edit tool.
With context, up to sixteen edits[] entries can travel in one exchange.
They are applied in order, all-or-nothing: the file is written exactly once,
after every replacement in the sequence has succeeded, or not at all, and a
refusal names the 1-based failing index. The single old_text/new_text
form remains accepted for one replacement. Python normalizes the
workspace-relative path, matches
writable_paths, rejects every symlink component, checks the exact-byte
SHA-256 revision, and requires oldText to occur once. A success
atomically publishes the replacement and returns the next revision. A refusal
returns one of PATH_UNDECLARED, REVISION_UNAVAILABLE, REVISION_STALE,
ANCHOR_MISSING, ANCHOR_AMBIGUOUS, or
MUTATION_FAILED, with protocol exit 9; the TypeScript tool reports the
error and does not advance its revision map. A transport failure has an
indeterminate write result, so the adapter poisons that mutation context and
refuses later edits until E5 discards the isolated worktree.
The marked tests/test_integration_mutator.py fixture and
tools/exercise_mutator.mjs prove the mutation path without calling a model.
E5 creates the context and runs that same path inside E3's disposable
worktree.
Repeated-call protection
The same package installs a TypeScript guard on Pi's ordinary
tool_call hook. It remembers the last twenty admitted calls. When five calls
in that window have the same tool name and structurally identical JSON input,
the sixth is refused with a message asking the model to take a different
action. Object key order does not matter; array order does. A refused retry is
not added to the window, so repeating it remains blocked. Each block appends a
loop_broken entry. The third consecutive blocked call also ends the current
Pi turn, preventing a print-mode session from retrying the same refusal until
an outer timeout.
The state is local to one Pi registration and never carries into another
session or replay. The guard only sees schema-valid calls that reach
tool_call; it cannot stop a loop in Pi's earlier argument validation. It also
does not detect churn where calls keep changing their content. Contract-aware
path and revision enforcement belongs to E4's bounded replacement rather than
this always-on check; symbol preservation is built there (an edit or
write that would remove a symbol the accepted base defines is refused —
see /implement above).