Hashed Edit
Purpose
Catch stale-line edit failures BEFORE they corrupt a file. A stale-line edit happens when an agent reads a file, plans an edit, then submits the edit after another tool call has rewritten the line — the edit applies to the wrong content.
Adapted from oh-my-openagent's hashline_edit pattern (which reports
reducing stale-line edit failures from ~93% to 32% on Grok Code).
The mechanism: every line read through read-with-hashes.sh is tagged
with a content-hash anchor (<lineno>#<hash>|<content>). The edit
companion edit-by-hash.sh applies the edit IFF the current line's
hash matches the anchor. Mismatch → refuse + diff.
Scope
Two operations, no third:
- Read with hashes — produce tagged output an agent can refer back to
- Edit by hash — apply a single-line replacement (or deletion) gated by hash match
Multi-line edits and structural transforms (move blocks, refactor across
files) are out of scope. Use the standard Edit tool for those, or
break them into per-line hashed edits where the staleness risk is real.
When to use
- Long sessions on large files. A 2000-line file read at minute 5 may have changed by minute 35. Hash-anchored edits catch the staleness.
- Multi-agent dispatch on shared files. When multiple specialists may touch the same file, hash anchors make race-condition edits fail loud instead of silently corrupting.
- Models that struggle with line numbers. Some models drift between the line they think they're editing and the line they actually reference. Hash anchors catch the drift.
- Risk-asymmetric edits. Editing migrations, secrets-adjacent config, or anything where a wrong-line edit has compounding cost.
When NOT to use
- Single-line edits on a small file in a single agent's session — the
standard
Edittool's exact-string-match is enough. - Files the agent owns end-to-end with no concurrent writers — no staleness risk.
- Operations that don't fit the line-replace shape (full rewrites, block moves, format conversions).
Automated pass
Two scripts under scripts/:
read-with-hashes.sh <file> [--start N] [--end N]— reads the file (or a line range) and outputs each line in<lineno>#<hash>|<content>form. The hash is a 4-char hex digest (cksum % 65536) over the line content; ~65k buckets is more than enough collision space for a single file.edit-by-hash.sh <file> <lineno>#<hash> <new-content> [--delete]— applies the edit IFF the current line's hash matches the anchor. Mismatch produces a stderr diff and exit code 5.
The agent's contract:
- Read the target region with
read-with-hashes.sh. - Decide the edit. Reference the line by its anchor (
42#a3f1), not its line number alone. - Apply via
edit-by-hash.sh. - On exit code 5 (hash mismatch), re-read the region and retry. Don't paper over the mismatch — the staleness is real.
Manual pass
The lead does the verification the runtime would otherwise do:
- After a successful edit, re-read the surrounding region. The
anchor matched, but
read-with-hashesof the post-edit region is the audit trail proving the change is what was intended. - On exit code 5, treat the mismatch as signal, not noise. If the file changed since the agent's read, the agent's reasoning was based on stale content. Re-read, re-reason, retry — don't blindly force-apply the edit by re-computing the hash on stale assumptions.
- Log mismatches. A single mismatch is a race-condition warning; a pattern of mismatches against the same file means concurrent writers and the workflow needs sequencing, not retries.
Known gotchas
- Line endings matter.
cksumhashes byte-for-byte. A line that'sfoo\r\nand one that'sfoo\nproduce different hashes. If your files mix line endings, normalize before hashing or expect the hash check to flag the difference. - Trailing whitespace matters.
cksumincludes trailing spaces. A line with a trailing space hashes differently from one without. The hash mismatch you see may be a whitespace edit, not a content edit. - Empty lines hash deterministically. All empty lines produce the
same hash (
0000fromcksumof empty input is actuallyffffin cksum's representation, but consistent — empty hashes match each other). For edits to empty lines, line number alone disambiguates. - No multi-line replacement.
edit-by-hash.shreplaces ONE line. For multi-line replacement, delete N lines and re-insert via N separate edit calls, or use the standard Edit tool with full context. - Files without trailing newline. Edge case handled (the script falls back to a tail-based read for the last line) but verify on files where the final-line-no-newline matters semantically.
Future enforcement (v0.3+)
This release ships the helper scripts as opt-in tools. The next step
is a PreToolUse hook that intercepts every Edit / MultiEdit
call, detects when the agent's old_string doesn't match the file's
current content (i.e., the file changed since the agent's last
Read), and refuses the edit with a diff. That makes the staleness
check the default, not the opt-in.
The hook is deferred to v0.3 for design reasons, not Phase 0.5 reasons:
- The
Edittool's stdin payload shape is already known (hi_old_string,hi_new_string,hi_file_pathinlib/hooks/lib/hook-input.sh). - The interesting design question is what counts as stale —
Edit's exact-string-match semantics already catch some cases;
the hook should only fire when the agent's
old_stringmatches but represents content that was true at a previous Read. - Auto-enforcement changes the agent contract; the helper-script shape lets us iterate on the format before locking it.