Surgically edit existing text files. Prefer hash-verified anchored edits from fresh `read`, `grep`, `ast_search`, or `write` output; copy `LINE:HASH` anchors exactly.

> **Detailed reference document.** The provider-visible contract is [documented separately](../docs/tool-metadata.md) and consists of registered tool/parameter descriptions, snippets, and guidelines. This file is not loaded into `session.systemPrompt`. Changing this prompt body alone does not change provider-visible metadata.

`edit` requires the target file to have been anchored earlier in the current session. If you get `file-not-read`, run `read`, `grep`, `ast_search`, or `write` first.

## Variants

| Variant | Use | Anchors |
|---|---|---|
| `set_line` | Replace/delete one line | 1 |
| `replace_lines` | Replace/delete a contiguous range | 2 |
| `insert_after` | Insert after an existing line | 1 |
| `copy_lines` | Copy existing lines (from `path`, or another file via `from_path`) to after a line of `path` | 3 |
| `move_lines` | Move existing lines (from `path`, or another file via `from_path`) to after a line of `path` | 3 |
| `replace_symbol` | Replace one function/class/method/etc. | 0 (`symbol`) |
| `replace` | String replacement escape hatch; one match by default, all with `all: true` | 0 |

Set `new_text` (or `replace_lines` `new_text`) to `""` to delete the anchored line(s). For an intentionally blank line, use `"\n"` or whitespace content, not `""`.
Prefer `set_line`, `replace_lines`, and `insert_after`: they verify the file still matches the anchored content. Use `replace` only when anchors are impractical, such as repeated text across many unrelated lines.

`replace` is exact-only by default: missing `old_text` fails with `text-not-found`. Wrap old_text/new_text in {replace: ...} — a bare top-level `{old_text, new_text}` inside `edits[]` is rejected with guidance. `fuzzy: true` is a narrow fallback that only normalizes whitespace and confusable Unicode (e.g. smart hyphens) after exact matching fails; it is **not approximate or Levenshtein/semantic matching** and will not find renamed or reworded text. When fuzzy matching is used, the response warns that exact text was not found.

A `replace` whose exact `old_text` occurs once needs no prior `read`. If it occurs more than once and `all` is not set, the edit is refused with `ambiguous-match` and the matching lines with anchors; add surrounding text, set `all: true`, or use `set_line` with one of those anchors. Anchored edits and `replace_symbol` still need fresh anchors from this session.

To copy or move existing text, never retype it: use `copy_lines` or `move_lines`. They take the source range (`start_anchor`..`end_anchor`, inclusive) and `after_anchor` in the edited file, and preserve source row text, invisible characters, and internal line separators (the join back to target text uses the target boundary policy below). To insert at the end of a file, use its last line as `after_anchor`. With `from_path`, the source range is in another file: `copy_lines` leaves it there, `move_lines` also deletes it from that file in the same call. Source anchors need a read of that file too. A copy or move whose source lines changed since they were read is refused with fresh anchors, and nothing is written.

## Input shape

```json
{
  "path": "src/foo.ts",
  "edits": [
    { "set_line": { "anchor": "42:ab1", "new_text": "const x = 2;" } },
    { "replace_lines": { "start_anchor": "50:c3d", "end_anchor": "55:e4f", "new_text": "const y = 3;\nreturn y;" } },
    { "insert_after": { "anchor": "60:f5a", "new_text": "// TODO\n" } },
    { "copy_lines": { "from_path": "src/bar.ts", "start_anchor": "3:a1b", "end_anchor": "9:c2d", "after_anchor": "70:e3f" } },
    { "move_lines": { "start_anchor": "80:a1c", "end_anchor": "84:b2d", "after_anchor": "12:c3e" } },
    { "replace_symbol": { "symbol": "add", "new_body": "export function add(a, b) {\n  return a + b;\n}" } },
    { "replace": { "old_text": "value", "new_text": "result", "all": true } }
  ]
}
```

Use only the variant(s) needed for the task; the example shows all shapes together for reference. Each `edits[]` entry must contain exactly one variant key. `new_text` / `new_body` is plain file content — no hash prefixes or diff markers. A pasted row prefix is removed only when its `LINE:HASH|` is a real anchor of the file's current lines, or when every line carries a bare `HASH|` of this file; the result then warns. Look-alike text such as `12:30|meeting`, `abc|def`, or a leading `+` is written verbatim. Set `literal: true` on `set_line`, `replace_lines`, or `insert_after` to write `new_text` exactly as given (no prefix removal, and an `insert_after` payload that repeats its anchor row keeps that row).

## Line breaks and byte preservation

`replace` splices physical text: untouched regions retain their original UTF-8 bytes, and `new_text` retains the LF/CRLF sequences supplied in the request. Mixed-ending files are not converted to one file-wide newline style. A replacement that changes only a separator is a real mutation, not a no-op.

Matching first selects byte-exact `old_text` occurrences. Only when none exist, CRLF/LF-equivalent matching remains available for compatibility with read views; logical offsets are mapped back to original physical spans. `all: true` applies to the selected matching tier. A non-`all` request with multiple matches in that tier is refused. `fuzzy: true` remains an explicit whitespace/confusable-Unicode fallback and does not normalize untouched content or replacement payloads.

Anchored edits, `replace_symbol`, and verified pasted-row recovery operate on logical rows. Untouched rows keep their physical separators. Introduced internal boundaries use the first replaced row's separator, or the insertion anchor's separator, with the file's first ending (LF for files without endings) as fallback. The outgoing boundary of a replaced range retains the last removed row's separator. Existing blank-line and final-newline conventions are unchanged; no global trim is applied.

`copy_lines` and `move_lines` retain source row text and internal separators. The boundary joining the inserted block back to target text follows the target's local style. Copying from a BOM-prefixed file does not copy its BOM into the target. Cross-file source removals preserve untouched source regions and the source BOM.

Pending previews preserve physical candidate content for supported homogeneous edit families. Mixed-family, multiple-symbol, cross-file or otherwise unsupported projections may be skipped rather than showing a candidate inconsistent with execution. A skipped preview does not reject execution.

## Optional post-edit verification

`postEditVerify: true` opts into post-write persisted-content verification for this one call. It is default off: when omitted or false, successful edits use the normal fast path and do not perform an extra read-back check.

When enabled, `edit` first runs the normal validation and write path. Only after the write succeeds, it reads the file back from disk and compares persisted content to the exact physical candidate, including the original BOM. Verification does not convert line breaks or weaken byte-exact comparison. This is not syntax validation; syntax validation is the separate pre-write `syntaxValidate` / `PI_HASHLINE_SYNTAX_VALIDATE` guard described below.

## `replace_symbol`

Use `replace_symbol` to replace one function, class, method, interface, type, enum, or similar symbol. Query symbols like `read symbol:`: `Name`, `Class.method`, or `Name@<line>`.

Rules:
- Prefer an exact name, dotted path, or `@<line>`. Successful `replace_symbol` results expose `matchTier`: exact and normalized-exact matches are silent; prefix, camelCase, and substring matches emit `fuzzy-symbol-match` with the selected symbol and tier.
- Ambiguous `replace_symbol` diagnostics use the same five-row budget, parent-qualified names, exact omitted count, and dotted / `@line` selectors as `read({ symbol })`.
- Supported for TypeScript, JavaScript, Rust, and Java. For other languages, use anchored edits.
- `new_body` must not be empty or whitespace-only.
- Write `new_body` without extra leading indentation; `edit` re-indents it to match the original symbol.
- If `new_body` appears to declare a different symbol name, the edit still applies but returns a `name-mismatch` warning.
- Do not combine `replace_symbol` with anchored edits that touch the same lines. Duplicate/overlapping `replace_symbol` ranges are rejected.

## Stale anchors

If anchors no longer match, `edit` fails with a hash mismatch (`hash-mismatch`) and shows nearby current lines. Lines marked `>>>` include updated anchors:

```text
>>> 41:b34|  const renamed = 3;
```

Copy the updated `LINE:HASH` and retry; the rows shown in a refusal count as seen, so no re-read is needed. If the target moved farther away, re-run `read`, `grep`, `ast_search`, or `write` for fresh anchors.

Anchor hashes include whitespace: a reindented line is a changed line. An unchanged line that only moved is auto-relocated with a warning; a changed line is never matched to a "similar" line.

`edit` also refuses to overwrite any line it would replace or delete, including lines inside a `replace_lines` range or matched by `replace`, that changed on disk since you were last shown it. The refusal shows the current rows (`>>>` marks changed ones) with fresh anchors.

When your own edit changes a file's line count, the result warns `Line numbers shifted by +K from old line N`. Anchors you hold for lines at or after `N` are stale. On the next edit, an old anchor whose line moved is remapped to its new line (with a note) when that is unambiguous. It is refused with `hash-mismatch` and fresh rows, and nothing is written, when its old line number now holds identical content or its line was replaced or removed. Re-issue with the rows shown; no re-read is needed. Anchors above the first changed line, and anchors after a same-line-count edit, stay valid.

## Validation and warnings

- Files containing NUL bytes or malformed UTF-8 are rejected as binary before any mutation; the original bytes remain unchanged.
- All edits are checked before writing; if a hard validation fails, nothing is written.
- Anchored edits are validated against their resolved original-file targets before bottom-up application. Unsafe overlapping replacement/deletion targets, or an `insert_after` boundary consumed by another edit, fail with `overlapping-edit`; nothing is written. Submit disjoint edits, or put dependent changes in separate `edit` calls. A one-line replacement plus `insert_after` at that same stable line remains valid. Distinct `insert_after` operations sharing the same resolved anchor retain request-array order; identical duplicate insertions are applied once.
- An edit that produces the file's current content is a successful no-op: nothing is written and the result says `No changes made`. Do not retry it.
- A whitespace-only warning means formatting changed but behavior probably did not.
- Payload indentation is literal. Literal `replace` payloads also preserve their requested line breaks; row-based variants use the separator policy above. A replacement that repeats the untouched row just above or below its range duplicates that row (with a warning); an `insert_after` payload beginning with a copy of its anchor row has that copy dropped (with a warning).
- A `replace`-only success may include a reminder to prefer anchored edits next time.
- Edits are written atomically (temp file + rename); symlinks are written through to their real target and preserved, and hard links are updated in place.

Syntax validation runs before writing when supported:
- Supported: Rust, C++, C headers, Java.
- Default `warn`: write succeeds, but warnings include `syntax-regression: lines X-Y`.
- `block`: aborts without writing.
- `off`: skips validation.
- `PI_HASHLINE_SYNTAX_VALIDATE` can set the default mode.

Existing syntax errors are tolerated; the warning is for newly introduced parser errors.

## Diff data contract

Successful `edit` results include `details.diffData` and `details.ptcValue.diffData` in addition to the existing `details.diff` / `ptcValue.diff` string fields. The string fields remain the backward-compatible human-readable fallback.

Successful `edit` results also include `details.patch`: a standard unified diff (`---`/`+++` file headers and `@@` hunk headers) generated from the pre-/post-edit file contents. Use `details.patch` when you need a portable, tool-agnostic patch (e.g. to apply elsewhere); use the compact `details.diff` hashline string for human-readable in-session display. `details.patch` is additive and does not change `details.diff`.

`diffData` is a stable versioned contract:

```ts
type DiffData = {
  version: 1;
  entries: Array<
    | { kind: "context"; oldLine: number; newLine: number; text: string }
    | { kind: "add"; newLine: number; text: string }
    | { kind: "remove"; oldLine: number; text: string }
    | { kind: "meta"; text: string }
  >;
  stats: { added: number; removed: number; context: number };
  language?: string;
  blockRanges?: Array<{ kind: "add" | "remove"; startLine: number; endLine: number }>;
  inlineDiffs?: Array<{
    removeLineIndex: number;
    addLineIndex: number;
    removeSpans: Array<{ kind: "equal" | "remove" | "add"; text: string }>;
    addSpans: Array<{ kind: "equal" | "remove" | "add"; text: string }>;
  }>;
};
```

For compact one-line hashline diffs, `details.diff` remains compact, while `diffData.entries` uses expanded remove/add rows so renderers can show inline word changes without breaking hashline output.