The stale-write guard
# grade this stage (from your course directory)
npx theinference test --stage stale-write --watchYour str_replace is exact and unique — but it still trusts that the bytes it's editing are the bytes the agent read. They might not be. Between the view and the edit, a formatter, a hook, or a human could have rewritten the file underneath the agent. This step closes that gap: track each file's read-version when it's viewed, and reject any str_replace whose file has changed since — or was never read at all. This is the stale-write guard, and it's the last piece that makes the editor safe.
view("s.ts") → record versions["s.ts"] = hash(bytes)
edit("s.ts", …) → recorded? and hash(current bytes) === recorded?
yes → apply | no → reject (stale)
external write to s.ts → bytes change, recorded version no longer matches
edit("s.ts", …) → hash mismatch → "stale edit: s.ts changed since it was read"
What to build
Add the stale-write guard to Tools
In src/tools.ts, keep a versions map of path → hash on the Tools instance. In view, after reading a file, record versions[path] = hash(bytes). In strReplace, before doing anything else, check the guard: if path has no recorded version (never read) or hash(current bytes) !== versions[path] (changed since read), return an error result (stale edit: <path> changed since it was read) and do not write. Only when the version matches do you fall through to the exact/unique replacement from the previous step. A successful edit should refresh the recorded version to the new bytes.
// src/tools.ts — guard at the top of strReplace
const seen = this.versions.get(path);
const current = readFile(path);
if (seen === undefined || hash(current) !== seen)
return { content: `stale edit: ${path} changed since it was read`, is_error: true };
// …exact + unique replacement on `current`, then:
this.versions.set(path, hash(updated));
The guard has to run before the replacement, not after. If you apply the edit and then notice the version drifted, the damage is already on disk. A stale match can even apply cleanly — old_str may still exist in the changed bytes — which is exactly the silent clobber you're preventing. And don't forget the never-read case: a str_replace with no prior view has no recorded version and must be rejected just like a changed file, not waved through.
This is a process step. The grader runs program.ts str-replace <fixture> on a fixture whose case.json scripts a three-move sequence: view the file, an external write that changes its bytes, then an edit. Your replayed results must match expected.json — the view returns the line-numbered contents, and the edit returns {content:"stale edit: <path> changed since it was read", is_error:true}. The final file content must equal the externally written bytes, proving the rejected edit left the file untouched.