Read many ranges across many files, and apply many edits across them, in one invocation — and get a verdict for every edit. A plan that fails validation writes nothing, because a write that changed nothing is invisible.
The numbers — two calls for any N, shapes A–D — live in docs/measure.md. The six-guarantee comparison lives in docs/comparison.md. Model × score readings live in docs/model-benches.md.
Status: stable at v1.50.0 (2026-10-06), the tag cut from 777f166. Break campaign for it: docs/break/campaign-v1.50.0.txt, 59 probes, exit codes identical to v1.49.0 but rename-dest-toolong, 2 → 1 (ADR-132).
Decisions: docs/adr/. How a change reaches main: CONTRIBUTING.md. Driving it from a checkout: AGENTS.md.
Caller practices: BESTPRACTICES.md. Updating the binary: UPDATE.md.
Hosts: any shell (the CLI), any MCP host (mrw mcp, below), and opencode through its plugin.
OS=$(uname -s | tr '[:upper:]' '[:lower:]') # linux | darwin
ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
curl -fsSL -o mrw \
"https://github.com/atvirokodosprendimai/tool-multipathreadwrite/releases/latest/download/mrw-${OS}-${ARCH}"
chmod +x mrw && ./mrw versionWindows: mrw-windows-amd64.exe. Every release also carries archives
(mrw_<os>_<arch>.tar.gz / .zip) and a SHA256SUMS.txt.
mrw version prints the same string -v / --version already print. Extra
arguments are usage (exit 2).
mrw instructions prints the contract from the binary: use mrw always and plan
the activity, the two rules that produce most refusals, and the traps that make
a red run look green. Exit 0. No flags. mrw --instructions is refused, exit 2,
with a message naming mrw instructions; so is any other undefined flag that is
exactly the name of a subcommand of the command it was given to.
Go 1.26.6 or newer (go.mod). One dependency, no cgo:
go build -o bin/mrw ./cmd/mrw # Linux, macOS
go build -o bin/mrw.exe ./cmd/mrw # Windowsgo test ./... needs only Go. scripts/contract.sh and scripts/measure.sh
need bash (WSL or Git Bash on Windows) — see CONTRIBUTING.md.
mrw read internal/apply/apply.go:1-40
mrw read a.go:1-8,100-130 b.go:/func Handle/,/^}/ c.go --stat
mrw read --grep 'func Handle' -C 3 --exclude vendor --exclude '*_test.go' internal/A range is 3-6, 5, 3- (to EOF), -20 (from the start), A,+N (line A
plus the N lines after it, so 12,+2 is 12–14), /pattern/, or
/start/,/end/. $ is the last line. Output ranges print as @@ 3-6, which is
the address a write plan takes.
--grep walks and serves in one call. A named directory is walked; with no
paths the walk starts at --root. --exclude GLOB is repeatable and matches
both the root-relative path and the basename — that is what makes '*_test.go'
work at any depth. Inside a git checkout it skips what .gitignore and
.git/info/exclude ignore, and any walk skips a binary file; a -- skipped:
line counts them, and --no-ignore walks every file. It does not enter a
nested repository — a directory below the checkout holding its own .git — as
git does not (ADR-130); a path you name inside one is walked. A .git/ the walk meets
is skipped; one you name is walked.
A link to a directory is not followed: one the walk meets is skipped, one you name is refused with
the directory to name instead — unless you spell it as an absolute path, which names the directory
it leads to — and one that leads to the root is walked as the root.
--files-from FILE|- is the same idea for a searcher you already trust:
rg -l 'func Handle' . | sed 's|$|:/func Handle/|' | mrw read -C 3 --files-from -| flag | effect |
|---|---|
--stat |
length, bytes and sha only — no content, so it licenses nothing |
-C N |
context around a single-pattern match |
--max-lines N |
cap per spec, where zero means zero: 0 serves nothing. Omit the flag for no cap |
--grep PATTERN |
serve every regexp match under the given paths (measured 2026-09-03: the walk took 0.76× the time of a grep -rl … | mrw read --files-from - pipeline over this repository, ADR-007) |
--ast-grep PATTERN |
serve every ast-grep hit (binary on PATH; missing is exit 2; a hang is, on unix, sent SIGTERM at 2 s and killed by 3 s if it ignores it; on Windows it is killed at 2 s). A hit in a file whose lines end in \r alone is reported, not served — read that file directly |
--exclude GLOB |
skip matching paths (needs --grep or --ast-grep) |
--files-from FILE|- |
one spec per line |
A pattern that matches no file is reported by name and exits 1.
A shell glob and an address suffix do not mix. mrw read 'dir/*.go:1-3'
takes the star literally and reports the path UNREADABLE; unquoted, zsh refuses
it first. Use --grep or --files-from.
A plan is a sequence of hunks. Every address resolves against the original file — no offset arithmetic between hunks.
@@ internal/apply/apply.go 42-58 replace anchor="func Apply" lines=17
... new lines ...
@@ internal/apply/apply.go 12 insert-after
"sort"
@@ README.md 3-4 delete
@@ docs/new.md - create
# a new file
mrw write plan.mrw
mrw write --dry-run plan.mrw
mrw write --no-check plan.mrw
mrw write --json plan.mrw
mrw write -Ops are replace, insert-after, insert-before, delete, create,
unlink, rename. @@ path - unlink removes the path. @@ old - rename
with a one-line dest body moves it, edited first when the plan also edits it; on a filesystem that folds case it may change only the case of the name (a.txt → A.txt, ADR-129). Only delete may carry no body among
the line-range ops: a lost body reads like one never written, so an
empty file is @@ new.txt 0 create body=0. A bare create with nothing under
it is refused and leaves no file behind.
--format=apply_patch compiles a Codex *** Begin Patch document to that same
native plan. --format=search_replace compiles Aider SEARCH/REPLACE. Both still
refuse an unread sibling and write nothing. A git patch is not an apply_patch;
--format=git is usage. The flag is required — there is no auto-detect.
Sequential apply_patch — one hunk, then another — is the leak, not the feature.
anchor= is required on a replace that addresses more than one line. Take it
from the NNN| content a read printed; one typed from memory can be wrong in
the same way the address is. sha= and lines= are optional and are checked
on every op, insertions included.
Addresses are 1-based and inclusive; $ is the last line, and A,+N is the line A plus the N lines AFTER it. A read CLAMPS a relative end at the last line; a write REFUSES one that runs past it. /from/,/to/ means the same on both paths: the end is the first match
at or after the start. A write refuses unless the start matches exactly once, or
names the Nth match with occurrence=N — refused unless every match before it has
been served (ADR-118); a read serves a span for every start match that is not
already inside a span it served.
These are gates, not a tour of the records behind them.
- All-or-nothing. Any hunk that fails validation writes nothing. Siblings
report
skip, neverok. A filesystem failure while committing reports what reached disk:PARTIALLY APPLIEDnaming what landed, orNOTHING WRITTENwhen the undo put everything back. - Per-line licence. Being served lines 1–5 does not license line 40.
--statand a match that printed nothing observe nothing. A file mrw just wrote is wholly known: a chain of edits needs no re-read. - One writer at a time. Writers on one checkout take turns (ADR-075). A writer waits up to 120 s for the
one before it, then is refused, exit 2, nothing applied, naming the holder's pid;
MRW_WRITE_LOCK_TIMEOUTsets the wait in seconds,0for one try (ADR-110). - MCP ack. A served
mrw_readlicenses nothing until you sendackids. Send an id in ack only if you hold BOTH its open and close markers AND counted the N numbered lines the open marker says follow: one marker is not enough, because a cut starting inside a span leaves the other end. A checkpoint brackets consecutive lines only, and served lines holding bytes that are not valid UTF-8 get none: the JSON answer replaces those bytes, so edit such a file with the CLI (ADR-108). - Neighbour licence. A multi-line
replaceis refused unless a prior read already covered a line after End. Last line of the file is exempt.--echo-pad N(MCPecho_pad, default 0) prints N lines after an applied body so a surviving closer is visible; the hunk staysok. The pad is not a checker. - Check by default. A CLI write to a non-prose path runs the project's
check when one exists;
--no-checkopts out; a markdown-only plan does not spawn it. A{files}- or{dirs}-onlyscoped_checkstill runs on a.rswrite whenpackages()cannot map;{packages}-only still falls back. A non-prose hunk whose{}()[]nets moved prints a balance row and staysok— a balanced insert in the wrong place is invisible to it. A replace whose body ends in a closer (a fence,},</div>,@endif) that its range did not end in, and that one of the next four non-blank lines repeats, prints acloserrow and staysok, counted inhints(ADR-119); it is advice, never a refusal.mrw statsprints every outcome at zero —failed_checkand, since ADR-102,partially_appliedamong them — and a landed-writes line. - Steps after the check.
--then NAMEruns a step declared under"steps"in.quality-harness.json, and--then-sh 'CMD'an ad-hoc shell step; both repeat and run in command-line order after a landed write whose check passed, onwriteandcheck. The first that does not pass stops the rest, namedNOT RUN; exit 3 (2 if it could not start);--jsoncarriesthen: {steps, pruned_logs}. --then-sh runs any shell command it is given: a harness rule that allows mrw without reading its arguments allows arbitrary shell through --then-sh.could_not_startmeans the shell could not start (a missing command inside it is afail, exit 127); a step runs withMRW_STEP_DEPTHone deeper, and--thenis refused at depth 8, so a step that re-runs mrw with steps cannot recurse without end;"steps"is read only when a step is asked for. A step runs as written: a step command holding {files}, {dirs} or {packages} is refused, since mrw expands them only in scoped_check. A passing step prints the last line of its output under its verdict. A check, like a step, runs with MRW_STEP_DEPTH one higher than mrw's own; at depth 8 mrw starts neither: --then and --then-sh, a write whose check is due, and mrw check are refused, exit 2, before anything is written or run, while a write that starts no check still lands (--no-check writes without it). A command that clears the environment, such as env -i or sudo, restarts the count below it, as setsid leaves the process group. On unix a stopped check, step or ast-grep process group hears SIGTERM first, and whatever ignores it is killed a second later; an mrw killed that way can leave its own check running (ADR-095). On Windows a check, a step or ast-grep runs in a job object that is closed with it, so its grandchildren stop too and none can leave the job (ADR-120). - Advisories are counted where you read. The summary line says
N failed, A advisories, zero included, and the JSON receipt carriesadvisories. Three advisories in your last ten writes print apattern:line on the receipt.--strict-balance(MCPstrict_balance) is opt-in and refuses the wrap-tail shape — a single-line replace whose line's delimiters do not balance and whose body does not match — as a failed hunk, exit 1, nothing written. Both JSON receipts carrypatternon every write, andmrw statsprices the flag as writes land: how many it would have refused, and whether those writes then broke, held or went unchecked — the numbers the pre-registered default question (BACKLOG) is decided on. - Check miss refuses. An in-root
mrw checkmiss is exit 2 and names the path — not a silent whole-project PASS. Under--jsonevery refusalmrw checkmakes once its flags are parsed is one document,{"error": …}, with noexit_code; a flag the parser rejects, or an attached flag value with edge whitespace, is refused before that and prints nothing on stdout. When no check could run the receipt says"ran": falseand"exit_code": -1, the value every check or step with no exit status carries, never 0 (ADR-100, ADR-101). - The process is the verdict. A check that prints
PASSand exits 1 is a failure. Never read an exit code through a pipe:mrw write plan | headishead's status. - A path means what it says. A trailing
/names a directory, so a file spelleda.txt/is refused, and so is a rename tod/. A read-only file is refused for every op that would change it. On Windows a name the OS opens as a device (NULalways,CONand the rest where that Windows reserves them) is refused. The receipt names a symlink'starget, the directories a plan made (dirs_created), what it made and could not remove (left_behind), and a removed file's former sha. - A delete says what it removed. Its verdict carries
removed_firstandremoved_last, and adeletemay carry a body: the lines it expects to remove, refused before anything is written when the count differs (ADR-008).
After any multi-line body, read on past the range until the enclosing structure closes. mrw models no target syntax; the damage is never inside the lines you named, which is why the receipt cannot show it.
A plan names a file once, however it is spelled. Two spellings that reach the same file — case-folded names, or a file and a symlink to it — are refused with both named.
It will not write outside --root, even through a symlink or a Windows
junction, and not even through a directory another process swaps for a link
while the write runs: every change after validation goes through the root, held
open (ADR-106). It will not replace a symlink, and will not change your line
endings. A file a plan edits that another process replaces or rewrites after
mrw read it is refused by name rather than overwritten, when either of two
checks sees the change: the one before the first rename, which leaves nothing
written, or the one just before that file's own rename, where the commit stops
at that file (ADR-106). A change in the instant after the second check, or an
in-place rewrite that keeps both size and modification time, is not seen; and
the source of an unlink or a rename is not checked this way. Staging
failures write nothing; a later rename failure can leave a partial tree and
names the files already written. When two callers share a checkout, pin each
file with sha= from the read header (==> a.go … sha 1a2b3c4d), so a plan
written against a file the other caller has since changed is refused. The
records are in docs/adr/.
Each file lands by a rename, so no file is ever half-written. What a failed run
leaves depends on where it stopped (ADR-001, ADR-066, ADR-102, ADR-105). Anything
mrw made in the tree and could not take away is named in left_behind (left behind: <path> on the human receipt), on success and on failure.
| Stage | On disk | Receipt | Exit | Recovery |
|---|---|---|---|---|
| validation | nothing changed | the failing hunks with reasons, the rest skip, NOTHING WRITTEN; a file mrw cannot open names why — permission denied, held exclusively by another process, a name the system refuses (ADR-125) |
1 | fix the plan, or the file, and send it again |
| staging | nothing changed; the temp files and directories staging made are removed, and any that could not be are in left_behind (a temp another process moved away with its directory is at a path mrw never made, and is not named) |
the unstageable file's hunks fail with the filesystem error, the rest skip; on Windows a target held without delete sharing fails here, before any rename, naming that it is held (ADR-125) |
1 when the cause is the target's — held, a permission, a name refused, changed or removed since mrw read it; 2 for the environment — disk full, an I/O error, an error mrw cannot name (ADR-132) | fix the plan or the file, or the cause (space, permissions), and send again; delete what left_behind names |
| commit | the files renamed before the failure stay written (PARTIALLY APPLIED), or the undo put every one back (NOTHING WRITTEN) | files[].written says what landed; error names the step that stopped |
2 | read the written files; send only what did not land, never the whole plan again |
| undo | an unlinked file whose path a rename still holds stays in a .mrw-aside-* recovery file |
UNDO INCOMPLETE names the aside and the path it belongs at, and left_behind names the aside |
2 | settle what now holds that path first, then move the aside there; never move it over a file |
| ledger | the write landed; mrw could not record it | applied: true with error naming the ledger (CLI and mrw_write) |
2 | read the files again before the next edit |
| check | the write landed and is unverified | the check's verdict and full output:; a failing check that printed shows check last: above it |
3; 2 when no check could start | fix the code or the check; the tree is already changed |
| process death | a run killed mid-commit can leave some files renamed and others not, .mrw-* temps beside them, and a .mrw-aside-* that is either an empty placeholder (its file is still in place) or an unlinked file's only copy; mrw's state files are whole, since each is replaced by rename |
none: a killed run writes neither its undo nor a receipt | none | git status shows what landed; read each .mrw-aside-* before touching it — move a copy back only to a path that is missing, never over a file; delete the other .mrw-* temps; read before the next edit |
| power loss | staged tree files are not synced (+1335 % measured, ADR-105), so a write that returned can be lost; the ledger and the acknowledgement store are synced, but a name's directory is not, so either can come back as its previous version | the one you already had | none | check the tree; a ledger that came back older only refuses edits until you read the files again |
Two tools: mrw_read (specs) and mrw_write (plan). Same engine, same
ledger.
{
"mcpServers": {
"mrw": {
"command": "mrw",
"args": ["mcp"]
}
}
}A write's check does not hold the server: requests sent while it runs are answered, and a call that sends
_meta.progressToken hears notifications/progress until its answer (ADR-121).
Use an absolute path for command if mrw is not on the host's PATH. Launch
with an explicit root when the host does not set CLAUDE_PROJECT_DIR:
mrw --root DIR mcp{
"mcpServers": {
"mrw": {
"command": "/absolute/path/to/mrw",
"args": ["--root", "/absolute/path/to/repo", "mcp"]
}
}
}On a host with a shell, prefer the CLI. Where you register the server decides
where its tools appear. At user scope (claude mcp add -s user mrw -- mrw mcp)
both tools load in every project on the machine; at local scope, run from one
checkout (claude mcp add -s local mrw -- mrw mcp), they load only there.
Choose user scope when every project should reach mrw over MCP, and local scope
when only one should.
format, echo_pad, and ack sit on the existing write/read — not a third
tool. format is plan (default), apply_patch, or search_replace.
echo_pad is the same opt-in pad as --echo-pad. ack is how a served read
becomes a licence. mrw_write runs the project's check after a write that touches
code, by the rule mrw write uses (ADR-113), and returns the verdict as check
(and drift); check: false turns it off. A check that ran and failed leaves the
write applied and the call not an error. The write lands before the check runs, so
a lost answer means read the files before re-sending the plan. then names steps declared in
.quality-harness.json to run after a passing check (ADR-115), their verdicts in the receipt's
then; ad-hoc shell (--then-sh) stays on the CLI. Its receipt's hunks carry
the same balance field the CLI prints.
An argument a tool does not declare is refused, naming it and the arguments the tool takes; nothing is done.
Without --root, the server uses CLAUDE_PROJECT_DIR when the host sets it,
else its working directory. A silent fallback to / or $HOME is refused
(exit 2). An explicit --root is always honoured, including --root /.
An answer is bounded at 200,000 characters of encoded result. Set it with
mrw mcp --max-result-chars N or MRW_MAX_RESULT_CHARS. The flag beats the
variable; 0 means zero. A ceiling too small to report a write refuses the
write before anything is applied.
What mrw reads is bounded as well (ADR-104): a request line up to 64 MiB, a file up to 1 GiB,
an ast-grep answer up to 256 MiB; past each the answer says so by size.
grep / exclude map onto --grep / --exclude. ast_grep maps onto
--ast-grep. A grep too large to serve returns an index — one spec per
matching file, no content — which licenses nothing — and names every path the
walk could not use. Like a grep's, an ast_grep index pages with after: send the
same finder again with after set to next_index, until it is empty.
A read of ONE spec — a whole file, or path:N- — too large for the ceiling comes
back as a first page with next_read: send that back as specs to continue, and
repeat until it is absent. Any other read too large — several specs, or a closed
range, which already says what it wants — is refused, and the refusal says what
to do instead: ask for a narrower range, or name fewer files.
mrw_read's result is the served text, with no structuredContent (ADR-023);
the write result's structuredContent carries the receipt.
The server speaks MCP 2025-11-25 and 2025-06-18 through initialize, and
answers the version the host asked for. It also speaks 2026-07-28: a request
carrying _meta["io.modelcontextprotocol/protocolVersion"] is served per
request, with server/discover, resultType, and caching hints on
tools/list. A version it does not speak gets -32022 naming the ones it does.
A mistake inside a tool's arguments comes back as a tool result with isError,
which the model reads. A call that is not a valid request stays a JSON-RPC error.
mrw read 'internal/f.go:/^func main/' fails in Git Bash, and the error
names a line number you never typed. MSYS2 rewrites the argument before mrw
is started when its file part holds a /, and a one-letter pattern such as
/x/ becomes a drive. Quoting does not prevent this.
| environment | regex addresses |
|---|---|
| Git Bash / MSYS2 | fail |
Git Bash with MSYS_NO_PATHCONV=1 |
work |
Git Bash with MSYS2_ARG_CONV_EXCL='*' |
work |
| PowerShell | work |
| WSL | work |
Line-number, range and $ addresses are unaffected. A --grep pattern that
starts with / is rewritten too, into a false "no match".
| code | meaning |
|---|---|
| 0 | everything asked for succeeded |
| 1 | a hunk failed, or the answer is incomplete; nothing written |
| 2 | usage, parse or I/O error — including a check miss |
| 3 | the write applied and the check failed, timed out or was interrupted (before it started, too); the tree is changed and unverified. Not a rollback |
Exit 1 on read means incomplete: UNREADABLE, REFUSED, no match, or
WITHHELD. The output always names which.
mrw supports opencode through a plugin in this repository,
cmd/opencode/mrw-plugin/ (ADR-089). It gives an opencode session eight tools:
| tool | runs | arguments |
|---|---|---|
mrw_read |
mrw mcp's mrw_read |
specs, grep, astGrep, exclude, after, ack, maxLines, stat, filesFrom, root |
mrw_write |
mrw mcp's mrw_write |
plan, ack, dryRun, check, then, format, echoPad, strictBalance |
mrw_check |
mrw check |
paths |
mrw_stats |
mrw stats |
json |
mrw_seen |
mrw seen |
prune, dryRun |
mrw_iter |
mrw iter |
args: a verb and its specs, e.g. ["add", "a.go:10-20"] |
mrw_version |
mrw version |
none |
mrw_instructions |
mrw instructions |
none |
Read and write go through the MCP engine, because opencode cuts long output. It
truncates a tool's result at 2,000 lines or 50 KiB, and a CLI read has licensed
every line it served by then, so the hidden tail would be writable unseen. Over
mrw mcp, a read is a page under that limit, bracketed in -- ck checkpoints,
and licenses nothing until acknowledged. Send an id in ack only when both of its
markers and all its lines arrived; a cut page loses its tail's close markers, and
those runs stay unwritable (see MCP). mrw_write runs the check after a code
write, as mrw write does; check: false turns it off, and mrw_check runs it on
demand. The other six tools run the CLI and answer exit: N first, the CLI's
exit code (see Exit status). An answer the server marks as an
error starts error: — a write whose receipt says it applied still landed.
Build the plugin once:
cd cmd/opencode/mrw-plugin
npm ci
npm run buildThen list it in the workspace's opencode.json (this repository ships one), or in
your global opencode config with an absolute path, and restart opencode:
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["./cmd/opencode/mrw-plugin/dist/index.js"]
}The plugin runs bin/mrw (bin\mrw.exe on Windows) from the worktree when it is
there, and otherwise mrw on PATH. In a session with no git worktree it works in
the session's directory.
opencode is also an MCP host, so mrw mcp works there without the plugin: list it
under mcp in opencode.json as a local server running mrw mcp (see MCP).
opencode prefixes an MCP tool with its server's name, so a server named mrw
answers as mrw_mrw_read and mrw_mrw_write beside the plugin's tools. Both work
together, over the same engine and ledger; the plugin adds check, stats, seen
and iter.
cmd/opencode/mrw-plugin/test/smoke.test.mjs drives the built plugin's tools against
the built binary, a page cut the way opencode cuts one included, and CI runs it on
Linux. opencode itself is not run in CI.
mrw check, mrw iter, mrw seen, mrw seen --prune, and mrw stats exist
only on the CLI, and so through the opencode plugin, which runs it. mrw check is not read-only: it runs whatever the project
declared. Nothing calls --prune for you. Details: AGENTS.md.
Round trips are 2 for any N. Re-run ./scripts/measure.sh rather than quoting
a table — the ratios track this repository's own files. The contract is
./scripts/contract.sh; it prints its own total.