Move became rewrite
The failure
Section titled “The failure”Ask an agent to move code and it often doesn’t carry it — it reads what the code does and regenerates something equivalent at the destination. On large code that silently drops whatever it judged incidental: a comment kept on purpose, an exact type, an edge case. Nothing errors; the diff reads as a clean move. The fix is to make a move be a move — the destination must carry the origin’s bytes, not a reconstruction of them.
How it works
Section titled “How it works”The agent declares intent (#refactor) and a scope — the moves it commits
to. Then two things are checked independently:
-
A move that regenerated instead of carrying the bytes — caught preventively by the file-guard, before the write lands.
-
A declared move that never happened — caught at turn-end by the gate, which refuses the
Stop.
A context ties them together, tracking the scope until every move lands. A context can only track; only a gate blocks a turn.
The actual configuration
Section titled “The actual configuration”This is the real, working example shipped in the sloprail repo at
examples/deterministic-refactoring-mode/. Three natures, each a directory
under .sloprail/ — a *.yaml declaration plus the scripts it names:
Directory.sloprail/
Directorycontext/
Directoryrefactoring/
- context.yaml
- enter.sh
- exit.sh
Directoryfile-guard/
Directorymoved-content-reconciles/
- file-guard.yaml
- reconciles-against-origin.sh
Directorygate/
Directoryrefactor-complete/
- gate.yaml
- verify-declared-moves-landed.sh
- README.md
context
Section titled “context”Tracks the refactor; never refuses. On a marked write it activates the scope;
at Stop its enter reads the #refactor scope=… declaration and records the
declared moves. Its exit closes the context once the gate says the refactor
is complete — which is what carries a multi-turn refactor.
# Tracks a declared refactor; never blocks (the blocking is the paired Stop# gate `refactor-complete`, which reads the declared_markers this context's# enter writes). Two triggers:# - PreToolUse: activate the scope before a marked write, so the reconcile# file-guard's `match: context["refactoring"].active` is true at that write.# - PostTagWrite: fires at Stop once the turn is settled, so enter can read the# `#refactor scope=...` declaration and populate declared_markers before the# gate reads it (enters run before gates).on: - event: PreToolUse - event: PostTagWriteenter: ./enter.shexit: ./exit.sh#!/usr/bin/env bash# Extract the declared refactor scope from the trajectory and print it as this# context's payload. Each scope token is the fqn a landed `sr:moved-from` marker# will carry — `<path>@<sha>:<start>-<end>` — so the gate can later check for a# literal match. Printing nothing keeps the prior payload (it does not decline);# the gate permits on an empty scope, so a turn with no #refactor is harmless.set -uo pipefail
input="$(cat)"transcript_path="$(printf '%s' "$input" | jq -r '.transcriptPath')"
# The declaration is one message with the #refactor tag and a scope list, e.g.# #refactor scope=src/beta.go@<sha>:10-24,src/gamma.go@<sha>:3-9# Take the last entry carrying the tag and read its text. The tag lives at# .events[].tags[].label on a normalized entry (events are flat).decl="$(sr-session trajectory normalize \ --path "$transcript_path" \ --events PostTagWrite \ | jq -r ' def msgtext: if type == "string" then . elif type == "array" then [.[] | select(.type? == "text") | .text] | join("") elif type == "object" then [(.content // [])[] | select(.type? == "text") | .text] | join("") else "" end; [ .[] | select(any(.events[]?; .kind == "PostTagWrite" and any(.tags[]?; .label == "refactor"))) ][-1] // {} | .message | msgtext')"
if [ -z "$decl" ]; then exit 0fi
scope="$(printf '%s' "$decl" | grep -oE 'scope=[^ ]+' | head -1 | cut -d= -f2)"
jq -n --arg scope "$scope" \ '{declared_markers: ($scope | split(",")), declared_at: "trajectory"}'#!/usr/bin/env bash# Pure lifecycle: consulted on a Stop while the context is active, this only# decides whether to close the context — it cannot block (that's the gate's job).# The refactor is finished exactly when the completeness gate passed this Stop,# so read that verdict rather than re-deriving it. Staying open across a failing# Stop is what carries a multi-cycle refactor.set -uo pipefail
input="$(cat)"
# Nothing declared -> nothing to stay open for.declared="$(printf '%s' "$input" | jq -r '.currentContext.payload.declared_markers[]?' 2>/dev/null)"if [ -z "$declared" ]; then exit 0fi
# Default "fail" (context stays open) when the gate has no recorded verdict —# the more-guarding direction.status="$(printf '%s' "$input" | jq -r '.gates["refactor-complete"].status // "fail"' 2>/dev/null)"
if [ "$status" = "pass" ]; then exit 0fi
exit 1file-guard
Section titled “file-guard”Runs before a marked write lands, only while the context is active. It reconciles the moved bytes against their pinned origin; anything that doesn’t reconcile is a rewrite, and a rewrite is refused.
# Reconcile a moved file against its origin — but only inside a declared refactor# (the context link is a variable in the match, not a flat active_in) and only# for a file carrying an sr:moved-from marker. The same marker outside the# context is not this guard's business.match: context["refactoring"].active and any(markers, .kind == "moved-from")preventive: truechecks: - script: ./reconciles-against-origin.sh#!/usr/bin/env bash# A file carrying an sr:moved-from marker must be byte-identical to its origin at# the pinned commit, minus imports and whitespace. The marker's fqn carries# <path>@<sha>:<start>-<end>.set -uo pipefail
input="$(cat)"
# resultKnown, not has("newContent") — newContent is ALWAYS a present key on# PreFileCreate/PreFileUpdate (the flat-event-fields discipline), so# has("newContent") is always true and reading an absent value as "" is# indistinguishable from a write that genuinely empties the file. The actual# "this engine could not predict the result" signal is resultKnown, which was# never consulted. This guard is preventive, so the engine's own dispatch# already refuses any underivable Pre write before this script ever runs# (services/sr-session/nature_fileguard.go's isUnderivablePreWrite) — this# check is real defense in depth, not the only line of defense, but a check# script must not trust its caller unconditionally.kind="$(printf '%s' "$input" | jq -r '.event.kind // empty')"case "$kind" in PreFileCreate|PreFileUpdate) known="$(printf '%s' "$input" | jq -r '.event.resultKnown // false')" if [ "$known" != "true" ]; then exit 0 fi ;;esac
new="$(printf '%s' "$input" | jq -r 'if .event | has("newContent") then .event.newContent else null end')"markers="$(printf '%s' "$input" | jq -c '.event.newMarkers // []')"
if [ "$new" = "null" ]; then exit 0fi
fqn="$(printf '%s' "$markers" | jq -r '[.[] | select(.kind == "moved-from")][0].fqn // ""')"if [ -z "$fqn" ]; then exit 0fi
# <path>@<sha>:<start>-<end>path="${fqn%@*}"; rest="${fqn#*@}"sha="${rest%%:*}"; range="${rest#*:}"start="${range%-*}"; end="${range#*-}"
origin="$(git show "$sha:$path" 2>/dev/null | sed -n "${start},${end}p")" || { cat <<EOF{"reason":"moved-from origin '$fqn' names a commit or path this checkout does not have — a move cannot be verified against bytes that are not here."}EOF exit 1}
# Drop imports and normalize whitespace on both sides, so a legitimate import# rewrite is not read as a rewrite of the moved code.normalize() { grep -vE '^\s*(import|from)\b' | sed 's/[[:space:]]\+/ /g;s/^ //;s/ $//'; }moved_body="$(printf '%s' "$new" | grep -vE '^\s*//\s*sr:' | normalize)"origin_body="$(printf '%s' "$origin" | normalize)"
if [ "$moved_body" != "$origin_body" ]; then cat <<EOF{"reason":"Content marked moved-from '$fqn' does not reconcile against its origin — after dropping imports and whitespace, the bytes differ. A move must carry the origin's bytes, not regenerated ones."}EOF exit 1fi
exit 0The piece that blocks. It wakes at Stop when a refactor is active, reads the
declared scope, and searches the tree for the sr:moved-from marker each move
should have left. Any missing → the turn is refused.
# The completeness half: every declared move must actually land. This is the# piece that blocks — a context cannot, only a gate does. Match on the context's# active flag skips the check when no refactor was declared; `require` orders the# context first, so the gate reads its settled declared_markers.on: - event: Stop match: context["refactoring"].activerequire: - context: refactoringchecks: - script: ./verify-declared-moves-landed.sh#!/usr/bin/env bash# Every declared move must have landed. The declared scope is the paired# context's payload (.context.refactoring.payload.declared_markers); `require`# guarantees it's settled before this runs. Each token is the fqn a landed# `sr:moved-from` marker carries, so "did this move land" is a literal search of# the tree for that marker — the file-guard has already checked the bytes.## Portability: no mapfile/readarray (bash 3.2 lacks them); sets are carried as# newline-delimited strings walked with `while read`.set -uo pipefail
input="$(cat)"
# Empty -> nothing declared -> permit (a missing scope must not be a false refusal).declared="$(printf '%s' "$input" \ | jq -r '.context.refactoring.payload.declared_markers[]? | select(. != "")' 2>/dev/null)"
if [ -z "$declared" ]; then exit 0fi
root="${SR_WORKSPACE:-.}"
# grep -F: the fqn is a literal (contains . @ : -). Leader-agnostic; .git skipped.missing=""while IFS= read -r fqn; do [ -z "$fqn" ] && continue if ! grep -rIF --exclude-dir=.git -- "sr:moved-from $fqn" "$root" >/dev/null 2>&1; then if [ -z "$missing" ]; then missing="$fqn" else missing="$missing, $fqn" fi fidone <<EOF$declaredEOF
if [ -n "$missing" ]; then jq -n --arg detail "$missing" \ '{reason: ("Refactor declared but not complete — these declared moves never landed as an sr:moved-from marker: " + $detail + ". Finish the moves you declared, or the turn cannot end.")}' exit 1fi
exit 0