Build your own Claude Code
Editing with guards·22 min

The stale-write guard

0 / 1
# grade this stage (from your course directory)
npx theinference test --stage stale-write --watch

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

A version per path, set on view, checked on edit
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

Your task

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.

tests/adapters.ts
// 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));
Reject before writing — never overwrite a file you read stale

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.

How it's graded

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.