Skip to content

Add edit: change lines by the anchors --hashline prints - #516

Merged
tobert merged 6 commits into
mainfrom
feat/edit
Oct 5, 2026
Merged

tobert merged 6 commits into
mainfrom
feat/edit

Conversation

@tobert

@tobert tobert commented Oct 4, 2026 •

Copy link
Copy Markdown
Owner

Stacked on #514 (atomic replace) and #515 (--hashline); merge those first and this diff shrinks to edit itself. Answers #375: patch stays a faithful GNU patch, and edit is the new tool for an agent changing lines it has read.

$ cat --hashline config.toml
3:a8c7:port = 8080
$ edit config.toml 3:a8c7 'port = 9090'
3:994f:port = 9090
$ edit config.toml 3:a8c7 'port = 1'
edit: config.toml: line 3 changed since you read it (now: port = 9090); nothing was written. Read it again before editing: cat --hashline config.toml

The grammar came from a model panel. With no help text, four model families (DeepSeek, Gemini, GPT, Claude Sonnet) all wrote edit FILE ANCHOR 'TEXT', pairs for a batch, a delete flag, and --after. Help text for exactly that shape, plus A..B ranges and --before, scored 24/24 with byte-identical answers across the four, so the heredoc, ed-script, and hunk candidates were not run. Anchors only, no str_replace mode: sed and patch already cover substitution and diffs.

edit config.toml --delete 6:8d62..10:2325
edit config.toml --after 2:7f4b 'workers = 4'
edit config.toml 12:5f5e 'enabled = true' 13:fbb6 'size = 512' 7:6ed2 'level = "debug"'   # one batch, all or nothing

Every anchor in one call refers to the file as read, so line numbers do not shift between the edits of a batch, and nothing is written unless every anchor matches. Edits that touch the same line, or an insert anchored on a line the call removes, exit 2.

A stale anchor exits 1, writes nothing, and shows the line's current text without a usable anchor. In the panel, all four models took a fresh anchor straight from the error and overwrote someone else's change, so the message asks for a re-read instead. Success prints the changed lines with their new anchors, ready for the next edit; past 40 lines it prints one summary naming tail -n +START --hashline FILE | head -n COUNT, and -q prints nothing.

The argv is bound verbatim and parsed in source order, so the word after an anchor is TEXT even when it starts with -. A TEXT of exactly --json or --help is taken by the kernel unless it follows -- (limits.md). Untouched lines keep their own line endings; new lines take the file's first, which fixes kaijutsu's CRLF bug. The file keeps its final newline or lack of one, except that an empty last line needs a terminator to exist.

The write is an atomic replace (WriteMode::Replace via the new cas_replace) after a re-read compared against the bytes the plan was built on. Under set -o trash the snapshot must equal those bytes too. A write landing between the final re-read and the rename is not detected; LocalFs has no primitive that closes that window.

Review (gpt-5.6-sol and deepseek via kaibo) found five defects, fixed in the last commit: the trash snapshot was used as the compare-and-set expectation (a race window), an empty last line in a file without a final newline panicked the report, mixed line endings were normalized, anchors were lowercased (breaking an uppercase custom hasher), and the suggested commands were not shell-safe for paths with spaces or a leading -. Each has a test. deepseek also listed every doc and comment that enumerates overwriters; edit is in all of them now. docs/editing-for-agents.md argued against an edit builtin; that section is rewritten with how each objection was answered.

Before kaijutsu adopts this, it needs to drop its own edit alias for vi, which would shadow the builtin.

Gates: cargo test --all, kernel --features subprocess,localfs, clippy --all-targets, rustdoc -D warnings, wasm32-wasip1 build. cargo-insta is not installed here; no pending snapshots.

🤖 Generated with Claude Code

tobert and others added 4 commits October 4, 2026 11:22
`patch` stays a faithful GNU patch; `edit` is the new tool for an agent
changing lines it has read (Amy, today, answering #375). It takes the
`LINE:HASH` anchors `cat --hashline` prints and checks each hash before it
writes, so an edit aimed at a line that changed fails instead of landing
on the wrong text.

The grammar came from a model panel. With no help text, four model
families (DeepSeek, Gemini, GPT, Claude Sonnet) all wrote `edit FILE
ANCHOR 'TEXT'`, pairs for a batch, a delete flag, and `--after`. Help text
for exactly that shape, plus `A..B` ranges and `--before`, scored 24/24
with byte-identical answers across the four, so the heredoc, ed-script,
and hunk candidates were not run. Anchors only, no str_replace mode:
sed and patch cover substitution and diffs (Amy).

    edit config.toml 3:a8c7 'port = 9090'
    edit config.toml --delete 6:8d62..10:2325
    edit config.toml --after 2:7f4b 'workers = 4'
    edit config.toml 12:5f5e 'enabled = true' 13:fbb6 'size = 512'

Every anchor in one call refers to the file as read, so line numbers do
not shift between the edits of a batch, and nothing is written unless all
match. Edits touching the same line, and an insert anchored on a line the
call removes, exit 2. At a gap, --after text from the line above comes
before --before text from the line below.

A stale anchor exits 1, writes nothing, and shows the line's current text
without a usable anchor: in the panel, all four models took a fresh anchor
straight from the error and overwrote someone else's change, so the
message asks for a re-read (Amy). Success prints the changed lines with
their new anchors, ready for the next edit, or one summary line past 40
lines naming `tail -n +START --hashline FILE | head -n COUNT`; -q prints
nothing (Amy).

The argv is bound verbatim and parsed in source order, so the word after
an anchor is TEXT even when it starts with `-`. New lines take the file's
own line ending, including inside multi-line TEXT, which fixes kaijutsu's
CRLF bug, and the file keeps its final newline or lack of one.

The write is an atomic replace (WriteMode::Replace via the new
cas_replace) after a re-read compare against the trash gate's snapshot or
the bytes edit planned on. A write landing between that re-read and the
rename is not detected; LocalFs has no primitive that closes it.

docs/editing-for-agents.md argued against an edit builtin; its section is
rewritten with how each objection was answered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review (gpt-5.6-sol and deepseek) found five defects.

Under set -o trash, edit compared the file against the trash snapshot
instead of the bytes it planned on. The snapshot is read after the plan,
so a write landing between the two matched the snapshot and was
overwritten with a plan built from older text. edit now compares against
the planned bytes and refuses when the snapshot differs from them. A
trash test pins that edit snapshots like patch and sed -i.

An empty last line in a file without a final newline panicked the report
(it indexed a line str::lines does not return) and could vanish from the
file. An empty last line only exists with a terminator after it, so the
file gains one in that case; the report reads the planned lines instead
of re-splitting the content.

A file with mixed endings was rewritten with one terminator throughout,
changing lines no anchor named. Untouched lines now keep their own
ending; new lines take the file's first.

Anchors were lowercased, so a configured hasher that prints uppercase
could never match. They are compared exactly.

The re-read and view commands edit suggests were not shell-safe: a path
with a space split into two words, and a leading `-` read as a flag.
They quote the path, after `--` when it starts with `-`, and tests run
each suggested command as written.

Also: edit joins the README builtin table, the trash and replace lists in
LANGUAGE.md, the help fragments, EMBEDDING.md, limits.md, and the kernel
doc comments that enumerate overwriters; json_sweep_tests has its case.
A TEXT that is exactly --json or --help is taken by the kernel unless it
follows `--`; limits.md says so and a test pins the `--` route.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tobert added a commit that referenced this pull request Oct 5, 2026
…#518)

Stacked on #514 (atomic replace). Review after #514; GitHub retargets this to main when #514 merges. The `cas_replace` hunk in `tools/context.rs` is the same text as on #516 (`feat/edit`), so the two branches merge cleanly.

Under `set -o trash`, `sed -i` and `patch` took the trash snapshot, then read the file again and transformed that second read. A write that landed between the two was transformed and replaced, while the trash held the older bytes, so the newer version had no copy anywhere. A mock trash that writes the file right after the snapshot reproduces it: both builtins exited 0.

Both now start from the snapshot bytes when there is one (`read_for_replace`), and replace through `cas_replace` with what they read as the expectation. A change after the snapshot is a conflict, and the racer's bytes stay on disk. A file named twice, or a multi-group patch on one target, starts each pass from what this command last wrote there.

```
$ sed -i 's/old/new/' f.txt
sed: f.txt: invalid operation: /work/f.txt: changed since kaish read it, so it was not overwritten; read it again and retry
```

OverlayFs resolved symlinks one layer at a time. A read followed a link inside the upper, and on NotFound read the same path from the lower, where the lower followed it. A write did not follow links at all. Two kaibo reviews (gpt, deepseek) traced what this did once writes started following links and reads did not: under trash, `sed -i` through a lower link to a copied-up file snapshotted and compared the lower bytes while the replace landed on the upper copy, and the version it replaced was lost. Other effects: an upper link to a lower-only file could not be read, a link to a removed file still read the removed bytes, `ls` through a directory link showed removed files, and `mkdir`/`symlink` through a lower directory link made an upper directory that hid the link.

OverlayFs now has one resolver, run with the state lock held. It walks the path one component at a time, looks each component up with whiteouts first, then the upper, then the lower, and follows a link in either layer from there. Every operation goes through it: read, read_range, stat, list, set_mtime, write, replace, and mkdir follow the last component; lstat, read_link, remove, and symlink keep it; canonicalize uses it. A `..` from a link target that climbs past the root is refused with PermissionDenied, "path escapes root", as LocalFs refuses it; an absolute lower target is mapped by the lower's own canonicalize.

Other fixes in the same code:

- `remove` of a copied-up file dropped its base, so the delete never reached `changes()` or `kaish-vfs commit`, and the base stayed charged to the budget. It now keeps a `Some` base.
- `commit_into` checked an Added path with `exists()`, which follows a dangling link; commit then wrote through the link. It now uses `lstat`.
- The trash decision read any `exists()` error as "new file" and any `stat()` error as size 0. One `stat` now decides both, and an error other than NotFound fails the command.
- The conflict error named an internal mechanism ("write-model gate"). It now names the fix.
- The `RecordingFs`/`GhostFs` test doubles in cat, cmp, wc, and find forward `replace`.
- New LocalFs test: overlay commit replaces the file (new inode, an open handle still reads the old bytes). Switching commit back to `write` fails it.

`test_remove_symlink_write_is_modified_with_original_base` pinned the old result (writing through a dangling link replaced the link with a file). It now pins the link surviving, its target created, and `changes()` refusing.

Rules now in force: a read-modify-write transforms exactly the bytes the trash holds and replaces only if the file still holds them. Every OverlayFs operation names the same file for a path, and it is the file a real filesystem would name.

Known windows left, recorded for a later batch: `cas_write` is still a re-read then a replace, two backend calls; a write between them is not caught until a backend offers compare-and-replace as one step. `commit_into` has the same gap between its preflight and write phases. The `cas_overwrite` doc now says so.

Tests are failing-first commits: 2 kernel race cases, 2 kernel overlay cases, 12 OverlayFs unit cases.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
tobert and others added 2 commits October 5, 2026 12:15
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@tobert
tobert merged commit 7c2f437 into main Oct 5, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant