Skip to content

Move became rewrite

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.

Watch a move regenerate — then get refused

The agent declares intent (#refactor) and a scope — the moves it commits to. Then two things are checked independently:

  1. A move that regenerated instead of carrying the bytes — caught preventively by the file-guard, before the write lands.

  2. 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.

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

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.

.sloprail/context/refactoring/context.yaml
# 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: PostTagWrite
enter: ./enter.sh
exit: ./exit.sh
.sloprail/context/refactoring/enter.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 0
fi
scope="$(printf '%s' "$decl" | grep -oE 'scope=[^ ]+' | head -1 | cut -d= -f2)"
jq -n --arg scope "$scope" \
'{declared_markers: ($scope | split(",")), declared_at: "trajectory"}'
.sloprail/context/refactoring/exit.sh
#!/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 0
fi
# 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 0
fi
exit 1

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.

.sloprail/file-guard/moved-content-reconciles/file-guard.yaml
# 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: true
checks:
- script: ./reconciles-against-origin.sh
.sloprail/file-guard/moved-content-reconciles/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 0
fi
fqn="$(printf '%s' "$markers" | jq -r '[.[] | select(.kind == "moved-from")][0].fqn // ""')"
if [ -z "$fqn" ]; then
exit 0
fi
# <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 1
fi
exit 0

The 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.

.sloprail/gate/refactor-complete/gate.yaml
# 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"].active
require:
- context: refactoring
checks:
- script: ./verify-declared-moves-landed.sh
.sloprail/gate/refactor-complete/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 0
fi
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
fi
done <<EOF
$declared
EOF
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 1
fi
exit 0